Algorithmische Abstraktion

Werbung
Kapitel 2
Algorithmische Abstraktion
2.0.1
Vorbemerkung zu Programmiersprachen
Die Vorlesung Praktische Informatik 1 ist nicht als Programmierkurs gedacht.
Natürlich ist es das Ziel, einfache Programme in den verwendeten Programmiersprachen Haskell und Python schreiben zu können und deren Bedeutung und
Auswertung zu verstehen. Es wird deshalb empfohlen, sich einführende Literatur zu den Sprachen Haskell und Python zu besorgen.
Das generelle Ziel ist das Verständnis von Programmiersprachen, algorithmischer Modellierung und Programmierkonzepten, auch solchen, die über die
Verwendung in gängigen Programmiersprachen hinausgehen. Damit soll eine
Grundlage gelegt werden für das Verständnis von Programmierung im allgemeinen und auch zur Einordnung von Programmiersprachen oder anderen formalen
algorithmischen Beschreibungssprachen.
Zur Begriffsklärung:
• Ein Interpreter führt ein Programm aus, (bzw.wertet ein Programm aus),
wobei er den Text des Programms als Grundlage hat. Jeder Programmbefehl wird einzeln eingelesen und dann ausgeführt (ausgewertet).
• Ein Compiler (Übersetzer) erzeugt aus einem textuell eingegebenen Programm ein auf einem Rechner ausführbaren Modul. Hier werden Programmumstellungen, Optimierungen usw. durchgeführt. Der Effekt des
Programms muss dabei gleich bleiben, aber der Ablauf orientiert sich nicht
mehr an der ursprünglichen Struktur des eingegebenen Programms.
Die eigentliche Ausführung des Programms erfolgt dann in einer Ablaufumgebung.
Natürlich gibt es Zwischenformen: z.B. Ein Interpreter, der vorher das Programm schon mal transformiert
Bei Ausführung eines Programms unterscheidet man (unabhängig) davon,
ob man einen Interpreter bzw. einen Compiler hat, die zwei Zeiträume:
1
Praktische Informatik 1, WS 2004/05, Kapitel 2, vom 27. Oktober 2004
2
• Compilezeit: hiermit klassifiziert man Aktionen bzw. Fehler, die beim Analysieren bzw. Übersetzen des Programms auftreten.
Z.B. statische Typüberprüfung, Optimierung.
• Laufzeit: hiermit klassifiziert man Aktionen und Fehler, die während der
Laufzeit des Programms auftreten.
Z.B. dynamische Typüberprüfung, Ein-Ausgabe.
2.1
Algorithmen und Programmiersprachen
Programmiersprachen lassen sich charakterisieren nach ihren wesentlichen
Merkmalen:
imperativ Ein imperatives Programm besteht aus einer Folge von Anweisungen, die sukzessive den Zustand, (d.h. den Speicherinhalt) einer Maschine (siehe von-Neumann-Architektur im Kapitel zu Python) manipulieren
(Seiteneffekte). Das Ergebnis des Programms ist der veränderten Zustand
der Maschine nach Durchlaufen aller Anweisungen.
D.h. in einem imperativen Programm wird präzise beschrieben, was (welche Anweisung), wann (Reihenfolge der Anweisungen) womit (Speicherplatz/Variable) zu geschehen hat.
prozedural: Vornehmlich als Mittel zur Strukturierung bieten mittlerweile alle imperativen Programmiersprachen (ausgenommen z.B. Ur-BASIC) die
Möglichkeit, Teile des Programmes in separate Unterprogramme auszugliedern. Diese Prozeduren werden dann an den benötigten Stellen mit
Argumenten aufgerufen. Diese Prozeduren können nicht nur vom Hauptprogramm aufgerufen werden, sondern auch untereinander. Damit soll die
Strukturierung und Übersichtlichkeit des Programms und die Wiederverwendbarkeit von Programmteilen erhöht werden.
funktional: Das Ergebnis eines funktionalen Programms ist durch die Auswertung eines Ausdrucks gegeben. Ausdrücke bestehen dabei aus der Anwendung von Funktionen auf ihre Argumente.
In einem funktionalen Programm formalisiert man also, wie die Lösung
zusammengesetzt ist bzw. was berechnet werden muss, und nicht, welche
einzelnen Schritte dazu durchzuführen sind.
Es gibt zwei Varianten der funktionalen Programmiersprachen: strikte
und nicht-strikte. Nicht-strikte funktionale Programmiersprachen sind pur
(d.h. haben keine Seiteneffekte), sind nicht imperativ, und verwenden
Funktionen sinngemäß wie Prozeduren Strikte funktionale Programmiersprachen können pur sein, haben aber im allgemeinen auch imperative
Anteile, da die Auswertungsreihenfolge fix ist.
deklarativ: Mit einem deklarativen Programm (Spezifikation) wird formalisiert, wie die Lösung aussieht, aber nicht, wie sie berechnet werden soll.
D.h. diese Spezifikation ist zunächst mal nicht algorithmisch.
Praktische Informatik 1, WS 2004/05, Kapitel 2, vom 27. Oktober 2004
3
Es gibt deklarative Spezifikationsmethoden, die eine nicht-algorithmische
Spezifikation ausführen können, so dass man eine Spezifikation des gesuchten Resultats auch als ein Programm interpretieren kann.
”
”
Zur Angabe der Spezifikationen werden meist formale Logiken zu Hilfe
genommen (logische Programmierung, Prolog).
objektorientiert: Objektorientierte Programmierung geht einher mit einer
Strukturierung des Programm in Klassen. Auch hier ist das Ziel eine bessere, übersichtlichere Strukturierung und eine Wiederverwendung von Programmteilen.
Die Ausführung des Programms basiert auf dem Austausch von Nachrichten zwischen Objekten. Ein Objekt kann auf eine Nachricht reagieren,
indem es weitere Nachrichten an andere Objekte versendet und/oder seinen internen Zustand manipuliert.
Normalerweise ist die Objektorientierung ein Konzept, dass in imperativen
Programmiersprachen eingesetzt wird.
typisiert: Alle Programmiersprachen sind mehr oder weniger stark getypt.
Man unterscheidet zwischen
• statischer Typisierung: Typisierung, die sich auf den Programmtext,
d.h. die Variablen, Funktionen, Prozeduren, Daten usw. bezieht.
• dynamischer Typisierung: Zur Ausführungszeit des Programms werden die Daten nach Typen klassifiziert, Die Prozeduren prüfen die
Typisierung der Eingabedaten und der verwendeten Übergabedaten.
Man unterscheidet auch zwischen
• schwacher Typisierung: Normalerweise mit dynamischer Typisierung
gekoppelt. Es sind Programme erlaubt, die einen Typfehler zur Laufzeit machen können.
• starker Typisierung: Es wird zur Laufzeit kein Typfehler erlaubt, indem der Kompiler Programme ausschließt, die bestimmte Typbedingungen nicht erfüllen.
Viele Programmiersprachen vereinigen unterschiedliche Aspekte, z.B. ist
Haskell funktional und stark getypt; es ist auch in gewisser Weise deklarativ. Bei
der Programmierung in der imperativen Sprache C kann man zu einem gewissen
Grad funktionale Prinzipien anwenden, usf.
Desweiteren bestehen zwischen verschiedenen Programmiersprachen ein und
derselben Gruppe oft auch Unterschiede in Bezug auf die Unterstützung wichtiger Strukturierungsprinzipien wie Abstraktion, Kapselung, Hierarchisierung und
Modularisierung.
Praktische Informatik 1, WS 2004/05, Kapitel 2, vom 27. Oktober 2004
4
Programmiersprachen: Wie sie idealerweise sein sollten:
• sind eigenständige Formalismen, mit der Möglichkeit der Formulierung
von Algorithmen als Programme.
• Es gibt einen Formalismus, der die Auswertung beschreibt, unabhängig
von einem bestimmten Computertyp (oder benutzten Umgebung). D.h.
sie haben eine formal klare und eindeutige operationelle Semantik. D.h.
eine Spezifikation eines maschinenunabhängigen Interpreters.
• Es gibt zumindest theoretisch die Möglichkeit, Programme formal zu
verifizieren. D.h. nachzuweisen, dass sie alle gewünschten AbbildungsEigenschaften besitzen.
2.1.1
Eine funktionale Programmiersprache: Haskell
Diese Programmiersprache ist funktional, nicht-strikt, hat ein polymorphes und
starkes Typsystem. Da sie ein reiches und klar strukturiertes Typsystem mit entsprechend flexiblen Datenstrukturen und gute Abstraktionseigenschaften hat,
werden wir sie in dieser Vorlesung für zahlreiche Algorithmen und zur Demonstration von Programmierkonzepten verwenden.
Wesentliches Konstrukt ist die Funktionsdefinition. Die Ausführung eines
Programms besteht in der Auswertung von Ausdrücken, die Anwendungen von
Funktionen auf Argumente darstellen. Die Anwendung einer Funktion auf die
gleichen Argumente soll immer das gleiche Ergebnis liefern: das ist die Eigenschaft der referentiellen Transparenz.
2.1.2
Eine prozedurale Programmiersprache: Python
Diese Programmiersprache ist prozedural, hat ein schwaches, dynamisches
Typsystem. Sie hat flexible Datenstrukturen und Objektorientierung.
Wir werden sie später verwenden, um imperative und prozedurale Konzepte zu demonstrieren. Zudem kann man dann die verschiedenen Ausführungen
derselben Konzepte vergleichen.
2.2
Algorithmen in einer funktionalen Programmiersprache: Haskell
Haupt-Konstruktionsmethoden von Algorithmen in Haskell sind die Definition von Funktionen, die Anwendung von Funktionen auf Argumente und die
Auswertung von Ausdrücken. Die Ausdrücke bestehen i.a. aus geschachtelten
Anwendungen von Funktionen auf Argumente, wie z.B. in f (g 4) (h 3) oder als
konkreteres Beispiel: sin (quadrat 3).
Wichtige Eigenschaften funktionaler Programmiersprachen wie Haskell:
Praktische Informatik 1, WS 2004/05, Kapitel 2, vom 27. Oktober 2004
5
Referentielle Transparenz Der
Wert
von
Funktionsanwendungen
(f t1 . . . tn ) ist nur durch den Wert der Argumente ti bestimmt.
Mehrfacher Aufruf einer Funktion mit den gleichen Argumenten ergibt
immer den gleichen Wert. D.h. es gibt keine Seiteneffekte, insbesondere
keine dauerhaften Speicheränderungen, die von anderen Funktionen aus
sichtbar sind.
In imperativen Programmiersprachen wie Python, Java, C, Pascal usw.
gilt das i.a. nicht, wie wir später sehen werden.
Verzögerte Auswertung Nur die für das Resultat notwendigen Unterausdrücke werden ausgewertet.
Polymorphes Typsystem Nur Ausdrücke, die einen Typ haben, sind
zulässig, Man kann Funktionen schreiben, die Argumente von verschiedenem Typ bearbeiten können. Z.B. einen Typ ∀a.a → a haben, das wäre
der Typ einer Funktion, die ihr Eingabeargument wieder als Ausgabe hat.
Das Typsystem garantiert, dass niemals Daten vom Typ her falsch interpretiert werden.
Automatische Speicherverwaltung Anforderung von Speicher und Freigabe von unerreichbaren Objekten erfolgt automatisch.
Funktionen sind Datenobjekte Funktionen können wie Zahlen behandelt
werden, d.h. sie können in Datenobjekten als Teil auftauchen und sind als
Argument und Resultat von Funktionen zugelassen.
Als Haskell-Interpreter stehen zur Verfügung:
ghci (Glasgow Haskell Compiler interaktiv) und
Hugs 98 (Haskells User Gofer System)
Diese erlauben es, schnell komplexere Algorithmen zu entwerfen, zu testen und
zu analysieren.
Haskell ist reich an sauberen Möglichkeiten, Algorithmen und die zugehörigen Daten in modularer Weise zu strukturieren.
Grundprinzipien sind:
• Definition von Funktionen.
quadrat x = x*x
• Aufbau von Ausdrücken: Anwendung der Funktion auf Argumente, die
selbst wieder Ausdrücke sein können.
a*(quadrat x)
• Nur der Wert von Ausdrücken wird bei der Auswertung weitergegeben.
6
Praktische Informatik 1, WS 2004/05, Kapitel 2, vom 27. Oktober 2004
Vordefiniert sind: ganze Zahlen vom Typ Int mit |n| < 231 −1 = 2147483647,
beliebig lange ganze Zahlen (vom Typ Integer), rationale Zahlen (Rational,
Gleitkommazahlen (Float) und entsprechende arithmetische Operatoren. Auch
Zeichen (Character) ’a’, ’A’, . . . vom Typ Char sind schon vordefiniert. Strings
sind Listen von Zeichen, aber das wird noch genauer besprochen.
Funktionen haben immer einen Typ, wobei der Typ der formalen Parameter und des Ergebnisses im Typ vorkommen. Z.B. hat quadrat den Typ
Integer -> Integer. d.h. das Argument kann ein Ausdruck vom Typ Integer
sein, das Ergebnis ist dann ebenfalls vom Typ Integer. Allerdings ist das Typsystem allgemeiner, flexibler und damit auch komplizierter, quadrat hat gleichzeitig den Typ Int -> Int.
Man beachte, dass die Sprache Haskell ein komplexes Typsystem besitzt,
das nur typbare Ausdrücke zulässt. 1 Im Moment soll vom Typsystem nur das
Notwendigste erwähnt werden.
Beispiel 2.2.1 Definition eines Polynoms x2 +y 2 , passend zum Satz des Pythagoras, mit Eingabe x, y. Berechnet wird das Quadrat der Hypotenuse im rechtwinkligen Dreieck. Wir geben auch den Typ mit an in einer sogenannten Typdeklaration. Die Schreibweise Integer -> Integer -> Integer bei zweistelligen
Funktionen bedeutet, dass die beiden Argumente vom Typ Integer sein müssen,
und das Ergebnis dann vom Typ Integer ist.
quadratsumme:: Integer -> Integer -> Integer
quadratsumme x y = (quadrat x) + (quadrat y)
Hugs session for: ...
:load "..... :kap2-progs.hs"
Main> quadratsumme 3 4
25
<CR>
Definition 2.2.2 Die (vereinfachte) Syntax2 mittels einer kontextfreien Grammatik für eine Funktionsdefinition ist:
hFunktionsDefinitioni ::= hFunktionsnameihParameteri∗ = hAusdrucki
hAusdrucki
::= hBezeichneri | hZahli
| (hAusdrucki hAusdrucki)
| (hAusdrucki)
| (hAusdruckihBinInfixOpi hAusdrucki)
hBezeichneri
::= hFunktionsnamei | hDatenkonstruktornamei
| hParameteri | hBinInfixOpi
hBinInfixOpi
::= ∗ | + | − | /
Zur Erläuterung betrachten wir die Funktionsdefinition
quadratsumme
1 in
x y = (quadrat x) + (quadrat y)
ghci kann man mit : t expr den Typ von expr erhalten, wenn expr sinnvoll ist.
auch Unterlagen zu reguläre Sprachen und kontextfreie Grammatik
2 siehe
Praktische Informatik 1, WS 2004/05, Kapitel 2, vom 27. Oktober 2004
7
Diese Grammatik sagt, dass das eine Funktionsdefinition ist, quadratsumme
ist ein Funktionsname, x,y sind die formalen Parameter. Nach dem Gleichheitszeichen kommt ein Ausdruck, der als Ausdruck + Ausdruck interpretiert
wird, und + ein binärer Infix-Operator ist. Die beiden Ausdrücke um das +
sind jeweils (quadrat x) und (quadrat y), die entsprechend der Grammatik
Anwendungen sind: quadrat ist ein Ausdruck und x ein Ausdruck. Das Hintereinanderschreiben ergibt ebenfalls einen Ausdruck: der linke Ausdruck wird ist
die Funktion, der rechte Ausdruck das Argument. D.h. die Funktion selbst ist
möglicherweise erst nach einer Berechnung bekannt.
In Haskell sind Bezeichner im allgemeinen Namen, die mit einem Buchstaben beginnen müssen und dann aus Buchstaben und Ziffern bestehen. Es gibt
auch Bezeichner, die nur aus Sonderzeichen bestehen: diese sind dann nur als
Funktionsname erlaubt.
Die Parameter nennt man auch formale Parameter. Wir nennen diese manchmal auch Variablennamen bzw Variablen. Die Anzahl der formalen Parameter
einer Funktion f wird durch die Definition festgelegt. Wir nennen sie die Stelligkeit (ar(f )) der Funktion f . In einer Anwendung (f t1 . . . tn ) nennt man ti
die Argumente der Funktion f .
Einige Funktionsnamen und Datenkonstruktornamen sind schon im “Prelude“ evtl. vorhanden, weitere sind benutzerdefinierbar. Datenkonstruktoren
müssen mit Großbuchstaben beginnen, damit sie sich von Funktionsnamen unterscheiden. Beispiele sind: True, False. Es sind verschiedene Arten von Zahlen verfügbar: kurze ganze Zahlen (Int), beliebig lange ganze Zahlen (Integer),
Brüche (Rational), Gleitkommazahlen (Float).
Da Haskell sowohl Präfix als auch Infix-Operatoren und auch Prioritäten
für Operatoren hat, werden oft Klammern weggelassen, wenn das Programm eindeutig lesbar bleibt. Eine Spezialität ist das Weglassen der Klammern bei (links-)geschachtelten Anwendungen: s1 s2 . . . sn ist dasselbe wie
((. . . (s1 s2 ) s3 . . .) sn ). Partielle Anwendungen, d.h. Anwendungen von Funktionen auf zuwenig Argumente, sind erlaubt. Diese werden aber erst ausgewertet,
wenn in einem späteren Stadium der Auswertung die volle Anzahl der Argumente vorliegt.
Es ist zu beachten, dass es weitere Bedingungen an ein korrektes Programm
gibt: Es muss eine korrekte Haskell-Typisierung haben und es muss bestimmte
Kontextbedingungen erfüllen: z.B. die formalen Parameter müssen verschiedene
Namen haben. Außerdem müssen alle undefinierten Namen im rechten Ausdruck
einer Definition formale Parameter sein.
Zur Strukturierung und Trennung können außerdem noch die Klammern
“{“,“}“ und das Semikolon “; “ verwendet werden. Diese Syntax werden wir
schrittweise erweitern und mit Beispielen erläutern.
Die für den Benutzer relevante Syntax benutzt auch das Layout des Programms. D.h. die Einrückungen zeigen eine Strukturierung an, die intern eine
Klammerung mit {, } bewirkt. Dies ist ein syntaktische Strukturierung moderner Programmiersprachen. Die Einrückungen, die man ohnehin macht, um das
Programm lesbarer zu machen, werden ernst genommen und zur Strukturerkennung des Programms (vom Interpreter bzw Compiler) mit benutzt.
Praktische Informatik 1, WS 2004/05, Kapitel 2, vom 27. Oktober 2004
8
Wie in vielen Programmiersprachen gibt es verschiedene syntaktische Darstellungsweisen des Programms:
• Benutzer-Syntax: diese wird vom Programmierer benutzt, und kann eine Erweiterung, oder eine andere Darstellung sein. In Haskell sind die
Abweichungen von der (einfachen) kontextfreien Grammatik: Prioritäten,
Weglassen von Klammern, Layout-Regeln, . . .
• Interne Syntax: “Linearisierung“. Die Syntax der Sprache, die sich leichter
beschreiben lässt, und maschinenintern (leichter) verwendet werden kann:
Eine “Entzuckerung“ hat stattgefunden: Die Ausdrücke sind voll geklammert, alle Operatoren sind Präfix, Layout durch Klammern ersetzt, . . .
• Ableitungsbaum (Herleitungsbaum) Die Datenstruktur, die vom Kompiler
nach der syntaktischen Analyse erzeugt wird, und die jedes Zeichen (bzw.
Namen, Zahlen usw. : Token) des Programms in einen Baum entsprechend
der Grammatik einsortiert.
• Syntaxbaum: Eindeutige Darstellung des Programms und der Ausdrücke
in einem markierten Baum. Diesen kann man automatisch aus dem Herleitungsbaum durch Weglassen redundanter Zeichen/Token gewinnen. Evtl.
ist eine leichte Umstrukturierung notwendig. Hieraus lässt sich eindeutig
die Ausführung des Programms definieren.
Einen Syntaxbaum kann man oft auch direkt von Hand angeben. Es
kommt manchmal vor, dass der Interpreter (Compiler) einen anderen Syntaxbaum erzeugt hat als den erwarteten. In diesem Fall hilft manchmal
das Einfügen von Klammern in der Benutzer-Syntax.
Beispiel 2.2.3 Syntaxbaum zu if x <= 0 then 1 else x*(quadrat (x-1))
ifThenElse U
UUUU
ll
l
l
UUUU
lll
UUUU
l
l
l
UUUU
l
l
UUU*
ulll
∗
<=B
1
BB
uu
|
u
|
u
BB
||
uu
BB
uu
||
u
B
|
u
}|
!
zu
app
x
x
0
uu
uu
u
uu
zuu
quadrat
−
u
uu
uu
u
uu
zuu
x
1
Zwei Syntaxbäume zu 1*2. Der erste zu ∗ als zweistelligem Operator, der
zweite zu ∗ als zweistelligem Operator, der erst auf das erste Argument, dann
auf das zweite angewendet wird.
Praktische Informatik 1, WS 2004/05, Kapitel 2, vom 27. Oktober 2004
1
2.2.1
∗>
>>
>>
>>
>
2
9
app
y BBB
y
BB
yy
BB
yy
y
B!
|y
appE
2
EE
||
E
|
E
|
EE
||
EE
~||
"
∗
1
Fallunterscheidung
Die Fallunterscheidung ist ein Ausdruck mit der Syntax:
if hAusdrucki then hAusdrucki else hAusdrucki
Diese Regel muss man dann in der einfachen Syntax oben zu den Fällen
für hAusdrucki hinzufügen. Die Worte if“, then“, else“ sind reserviert für
”
”
”
Haskell (sogenannte Schlüsselworte) und dürfen nicht als Funktionsnamen bzw.
Parameternamen verwendet werden.
Der erste Ausdruck ist eine Bedingung. Diese muss einen Booleschen Typ haben.
Der Versuch einer falschen Eingabe ergibt einen Typfehler (je nach Interpreter):
Beispiel 2.2.4
Prelude> (if 1 then 1 else 2)
ERROR - Illegal Haskell 98 class constraint in inferred type
*** Expression : if 1 then 1 else 1
*** Type
: Num Bool => Integer
Vergleichsausdrücke (vom Typ Bool) können gebildet werden mit den Infixoperatoren ==, <, >, <=, >=, / = Achtung: (= alleine ist reserviert für Funktionsdefinitionen, == ist der Vergleichsoperator). Weitere Boolesche Ausdrücke sind
Anwendungen von wahrheitswertigen Funktionen (Prädikate) auf Argumente.
Diese können auch benutzerdefiniert sein.
Boolesche Ausdrücke können kombiniert werden mit not, ||, && für die logischen Funktionen nicht, oder, und. Die Konstanten für wahr und falsch sind
verfügbar als True, False. (Achtung: erster Buchstabe groß geschrieben).
Definition 2.2.5 Man sagt f referenziert g direkt, wenn g im Rumpf von
f vorkommt.
Man sagt f referenziert g (indirekt), wenn es Funktionen f1 , . . . , fn gibt, so
dass gilt: f referenziert direkt f1 , f1 referenziert direkt f2 , . . . , fn referenziert
direkt g.
Eine Haskellfunktion f nennt man direkt rekursiv, wenn f sich selbst direkt referenziert, d.h. der Rumpf der Funktion wieder einen Aufruf der Funktion
f enthält.
Praktische Informatik 1, WS 2004/05, Kapitel 2, vom 27. Oktober 2004
10
Eine Haskellfunktion f nennt man rekursiv, wenn f sich selbst (indirekt)
referenziert.
Wenn f die Funktion g referenziert und g die Funktion f , dann spricht man
auch von verschränkter Rekursion. Das überträgt man auch auf allgemeinere
Fälle von n Funktionen.
Beachte: Wenn f eine rekursive Funktion aufruft, heißt das noch nicht, dass
f rekursiv ist.
Beispiel 2.2.6 Die Funktion
quadratsumme:: Integer -> Integer -> Integer
quadratsumme x y = (quadrat x) + (quadrat y)
ruft direkt die Funktion quadrat auf, diese wiederum die (eingebaute) Funktion ∗. Die Funktion quadratsumme ist somit nicht rekursiv.
Wir betrachten die mathematisch definierte Fakultätsfunktion n!,
0! := 1
n! := n ∗ (n − 1)!
die die Anzahl aller Permutationen einer n-elementigen Menge angibt, und
schreiben dazu eine Haskell-Funktion, indem wir die Definition direkt kodieren.
fakultaet:: Integer -> Integer
fakultaet x = if x <= 0 then 1
else x*(fakultaet (x-1))
Diese Funktion ist rekursiv, da sie im Rumpf sich selbst wieder aufruft.
Bei rekursiven Funktionen wie fakultaet muss man sich immer klarmachen,
dass man zwei Fälle beachten muss:
den Basisfall:
den Rekursionsfall:
Ergebnis 0 wenn das Argument x ≤ 1 ist.
Ergebnis: x*(fakultaet (x-1)), wenn x > 1 ist.
Zusätzlich muss man sich vergewissern, dass der rekursive Aufruf fakultaet
(x-1) auch näher an den Basisfall kommt, d.h. in diesem Fall, dass die Argumente kleiner werden und der Basisfall das kleinste Argument ist. Das ist hier
der Fall, da der rekursive Aufruf mit kleinerem Argument (nach Auswertung)
erfolgt.
Das funktioniert wie erwartet:
Main> fakultaet 3
6
Main> fakultaet 40
815915283247897734345611269596115894272000000000
Praktische Informatik 1, WS 2004/05, Kapitel 2, vom 27. Oktober 2004
11
Man sieht aber auch, dass diese Funktion bei negativen Eingaben auch terminiert, aber immer den (falschen) Wert 1 zurückgibt.
Betrachte folgende fehlerhafte Definition:
fakultaet_nt:: Integer -> Integer
fakultaet_nt x = if x == 0 then 1
else x*(fakultaet_nt (x-1))
Diese Funktion terminiert bei negativen Eingaben nicht, da z.B.
fakultaet_nt (-5) als rekursiven Aufruf fakultaet_nt (-6) hat usw., und
somit den Basisfall nicht erreichen kann.
2.2.2
Beispiel: Berechnung von Schaltjahren
Schaltjahre sind in unserem Jahreskalender notwendig, da das tropische Jahr
nicht genau 365 Tage ist, sondern 365.242190517 Tage. Dies wird im Gregorianischen Kalender durch Einfügen von Schaltjahren mit 366 Tagen korrigiert.
Der Haskell-Algorithmus dazu folgt. Er verwendet den Operator mod, der
durch Einklammern ‘mod‘ in einen Infix-Operator verwandelt werden kann.
ist_ein_schaltjahr n =
if n > 1582
then n ‘mod‘ 400 == 0 || (n ‘mod‘ 4 == 0 && n ‘mod‘ 100 /= 0)
else error "Jahreszahl vor Einfuehrung des Gregorianischen Kalenders"
Eine rekursive Funktion zum Ermitteln des nächsten Schaltjahres mit j ≥ n
ist:
naechstes_schaltjahr n = if (ist_ein_schaltjahr n)
then n
else naechstes_schaltjahr (n+1)
Der orthodoxe Kalender hat bei den durch 100 teilbaren Jahreszahlen eine andere Definition der Schaltjahre:
ist_ein_schaltjahr_ortho n =
if n > 1582 then
(n ‘mod‘ 100 == 0 && (n ‘mod‘ 900 == 200 || n ‘mod‘ 900 == 600))
|| (n ‘mod‘ 4 == 0 && n ‘mod‘ 100 /= 0)
else error "Jahreszahl vor Einfuehrung"
Man kann dazu folgendermaßen die mittlere Jahreslänge bestimmen:
mittlere_jahreslaenge::Double
mittlere_jahreslaenge = 365.242190517
mittlere_jahreslaenge_gregor =
fromInteger (mittlere_jahreslaenge_sum 2000 2399 0) / 400.0
mittlere_jahreslaenge_sum von bis summe =
Praktische Informatik 1, WS 2004/05, Kapitel 2, vom 27. Oktober 2004
12
if von > bis then summe
else if ist_ein_schaltjahr von
then mittlere_jahreslaenge_sum (von+1) bis (summe + 366)
else mittlere_jahreslaenge_sum (von+1) bis (summe + 365)
mittlere_jahreslaenge_ortho::Double
mittlere_jahreslaenge_ortho =
fromInteger (mittlere_jahreslaenge_ortho_sum 2000 2899 0) / 900.0
mittlere_jahreslaenge_ortho_sum von bis summe =
if von > bis then summe
else if ist_ein_schaltjahr_ortho von
then mittlere_jahreslaenge_ortho_sum (von+1) bis (summe + 366)
else mittlere_jahreslaenge_ortho_sum (von+1) bis (summe + 365)
Das erste unterschiedlich behandelte Jahr kann man ermitteln mit folgender
Funktion
schaltjahr_anders n =
if ist_ein_schaltjahr n == ist_ein_schaltjahr_ortho n
then schaltjahr_anders (n+1)
else n
2.2.3
Problemanalyse
Wir betrachten zunächst Algorithmen, die nur einfach strukturierte Daten (Zahlen) verwenden, so dass wir uns zunächst auf den reinen Algorithmenentwurf
konzentrieren.
Die Mittel zur Problemanalyse und zur Erstellung eines Algorithmus sind
dann:
• Zerlegung in (einfachere) Teilprobleme
• Lösen der Teilprobleme mittels Unterfunktionen
• Zusammensetzen des Algorithmus.
Wichtig bei diesem Verfahren der funktionalen Abstraktion ist
was die Funktion leistet, d.h. welche Abbildung definiert wird.
nicht: wie die Funktion realisiert wird.
Zentrale Leitideen beim Entwurf sind:
• Korrektheit
• Modularität
• Effizienz
13
Praktische Informatik 1, WS 2004/05, Kapitel 2, vom 27. Oktober 2004
2.2.4
Beispiel: Berechnung der Wurzel aus x mit dem
Newtonschen Iterationsverfahren (Verfahren von
Heron)
Dieses Beispiel demonstriert die Zerlegung der Aufgabe, eine Funktion zur näherungsweisen Berechnung der Quadratwurzel zu definieren. Das Ergebnis ist eine
deklarative Beschreibung, in der aber auch der geplante Ablauf zu erkennen ist.
Spezifikation der Wurzel:
√
x := y wobei y 2 = x und y ≥ 0.
√
Die Berechnung von x mit dem Newton-Verfahren geschieht wie folgt: Ein
√
x
Schätzwert s für die Wurzel x wird wiederholt durch 0.5(s + ) ersetzt, bis
s
alter und neuer Schätzwert keine große Differenz mehr aufweisen.
Die Funktionen dazu in Haskell:
wurzel x = wurzeliter 1.0 x
wurzeliter schaetzwert x =
if gutgenug schaetzwert x then schaetzwert
else wurzeliter (verbessern schaetzwert x) x
gutgenug:: Double -> Double -> Bool
gutgenug schaetzwert x =
abs(((quadrat schaetzwert) - x) / x) < 0.000001
verbessern schaetzwert x =
mittelwert schaetzwert
(x / schaetzwert)
mittelwert:: Double -> Double -> Double
mittelwert x y = (x + y) / 2.0
Eine Beispielauswertung:
*Main> wurzel 2.0
1.4142156862745097
2.2.5
<CR>
Semantik von Programmiersprachen
Programme sind zunächst mal nur Text. Programme sollen aber etwas im Rechner bewirken bzw. eine Funktion oder Funktionalität implementieren. Die Spezifikation dieser Wirkung bzw. der mathematischen Bedeutung nennt man Semantik eines Programms.
Es gibt verschiedenen Methoden der Definition einer Semantik der Programme einer Programmiersprache:
Praktische Informatik 1, WS 2004/05, Kapitel 2, vom 27. Oktober 2004
14
1. Operationale Semantik Die Wirkung und der Ablauf eines Programms
werden spezifiziert. Im allgemeinen modelliert man zunächst den Zustand,
i.a. ist das die aktuelle Speicherbelegung. Danach gibt man für jedes Programmkonstrukt an, welche Wirkung es haben soll, d.h. wie sich der Zustand verändert. Die Basis dafür ist normalerweise der Syntaxbaum des
Programms.
Das werden wir für Haskell im Anschluss machen. Es werden dann auch
einige Eigenschaften von Haskell nachgewiesen.
Für Python werden wir das genauer machen, um zu lernen, wie eine operationale Semantik für imperative Programmiersprachen aussieht.
Der Vorteil einer operationalen Semantik ist, dass die Spezifikation der
operationalen Semantik meist äquivalent dazu ist, einen Interpreter zu
spezifizieren.
2. Denotationale Semantik Jedem Programm wird eine mathematische
Funktion zugeordnet. Dazu muss man erst die Menge der möglichen Funktionen und Objekte mathematisch definieren (Domain). Danach gibt man
für jedes Programmkonstrukt an, wie man dazu eine Funktion bzw. ein
Objekt konstruiert. Das ist einfach z.B. bei if-Konstrukten, aber schon
etwas schwieriger z.B. bei einem while-Konstrukt in Python.
3. Transformations-Semantik Die Bedeutung eines Programms P wird
definiert durch eine Transformation P → P 0 , und dann durch die Semantik von P 0 . Wird meistens verwendet in Kombination mit einer anderen
Semantik. Z.B. um kompliziertere Konstrukte einer Programmiersprache
durch einfachere zu erklären.
Damit kann man für die volle Sprache eine operationale Semantik definieren, indem man eine Transformation in die Untersprache (Kernsprache)
angibt, und für diese eine operationale Semantik definiert.
Die Semantik von Haskell ist ebenfalls eine Transformationssemantik in eine Kernsprache. Die operationale Semantik der Kernsprache basiert ebenfalls auf Transformationen, ist aber keine Transformationssemantik.
4. logische Semantik Man wählt eine Logik und beschreibt die Eigenschaften von Programmkonstrukten mittels Axiomen einer Logik.
Schlüsse über Programmeigenschaften erhält man dann durch logisches
Schließen mit den Mitteln der verwendeten Logik.
Man kann z.B. in der Prädikatenlogik formulieren:
Für alle Eingaben n von natürlichen Zahlen liefert quadrat n
das Ergebnis n2 . Als Formel:
∀n : quadrat(n) = n2
Praktische Informatik 1, WS 2004/05, Kapitel 2, vom 27. Oktober 2004
2.2.6
15
Auswertung, operationale Semantik von einfachen
Haskell-Programmen
Jetzt haben wir genug Beispiele, um einen einfachen Ausschnitt aus der operationalen Semantik von Haskell zu definieren und zu verstehen. Auch wenn
diese operationale Semantik zunächst etwas gewöhnungsbedürftig ist, so hat sie
später den Vorteil, dass formal sauber geklärt werden kann, wie Funktionen auszuwerten sind, die auch Funktionen als Argumente oder Werte haben können
(sog. Funktionen höherer Ordnung). Ein weiterer Vorteil ist die Unabhängigkeit
von Compilern und spezifischen Rechnern (die Portabilität).
Wir beschreiben die Auswertung von Programmen als eine Folge von
Transformationen, die ein Anfangsprogramm P0 nacheinander in Programme
P1 , P2 , . . . transformiert, bis ein Programm entstanden ist, das sich nicht weiter transformieren lässt. Hieraus ist der Wert des Programms extrahierbar als
Ausdruck der an main gebunden ist.
Die Auswertungs-Transformation ist in jeder Situation eindeutig definiert. Wenn
die Transformationsfolge nicht endet, hat man ein nichtterminierendes Programm. Der Wert des Programms ist dann undefiniert.
Der Einfachheit halber betrachten wir zunächst nur einfache Haskellprogramme, die nur die bisher eingeführten Konstrukte benutzen:
• Zahlen, arithmetische Operatoren und Boolesche Werte und deren Operatoren.
• Funktionsdefinitionen
• if-then-else
• Anwendung von Funktionen auf Argumente
Definition 2.2.7 Ein Basiswert ist entweder eine Zahl, oder ein Zeichen vom
Typ Char oder einer der Booleschen Werte True,False.
Definition 2.2.8 Ein (einfaches) Haskell-Programm besteht aus:
• Einer Menge von Funktionsdefinitionen, wobei nur die bisher eingeführten
Konstrukte verwendet werden.
• Einem Ausdruck (main), der vom Typ eines Basiswertes ist. Der Wert
des Programms ist der Wert des Ausdrucks main nach Auswertung.
Das Prinzip der Berechnung ist:
Auswertung
=
Folge von Transformationen des Programms
bis ein Basiswert erreicht ist
D.h. es gibt eine Folge main → t1 → . . . tn . . ., so dass im Falle, dass es keine
weitere Transformationsmöglichkeit gibt und tn ein Basiswert ist, der Wert berechnet ist. D.h. entweder endet die Folge mit einem Basiswert (sie terminiert),
16
Praktische Informatik 1, WS 2004/05, Kapitel 2, vom 27. Oktober 2004
oder die Folge ist unendlich (sie terminiert nicht), oder es gibt einen Fehler. Der
erreichte Basiswert wird als Ergebnis des Programms definiert. Im Falle eines
Fehlers oder Nichtterminierung ist das Ergebnis undefiniert.
Wir verwenden die Notation P [t], die folgendes bedeuten soll: P [·] ist
ein Ausdruck (bzw. ein Syntaxbaum), der an einer Stelle einen Platzhalter (ein Loch) [·] hat. P [t] bedeutet den Ausdruck, bei dem t in das Loch
eingesetzt ist. Z.B., wenn P [·] = if s then [.] else 2, dann ist P [t] =
if s then t else 2.
Wir benötigen folgende Transformationen:
• die Definitionseinsetzung (δ-Reduktion):
P [(f t1 . . . tn )] → P [(Rumpff [t1 /x1 , . . . tn /xn ])]
Wenn f die Stelligkeit n hat und die formalen Parameter im Rumpf
von f die Namen x1 , . . . xn haben und Rumpff der Rumpf der Funktionsdefinition von f ist.
Der Ausdruck (Rumpff [t1 /x1 , . . . tn /xn ]) entsteht durch (paralleles bzw.
unabhängiges) Ersetzen der formalen Parameter xi durch die jeweiligen
Ausdrücke (die Argumente) ti . D.h. die Ausdrücke, die die Argumente
sind, werden textuell für die formalen Parameter eingesetzt. Das müssen
keine Basiswerte sein!
• Auswertung von arithmetischen Ausdrücken P [s op t] → P [r], falls s, t
arithmetische Basiswerte sind, und op ein zweistelliger arithmetischer Operator, und r das Resultat der arithmetischen Operation ist.
• Auswertung von arithmetischen Ausdrücken P [op t] → P [r], falls s ein
arithmetischer Basiswert ist, und op ein einstelliger arithmetischer Operator, und r das Resultat der arithmetischen Operation ist.
• Boolesche Operatoren: werden transformiert wie Funktionen, so wie diese
im Haskell-Prelude definiert sind.
Wir verwenden ersatzweise:
x && y = if x then y
else False
x || y = if x then True else y
not x = if x then False else True
• Auswertung einer Fallunterscheidung (if-Reduktion):
P [(if True then e1 else e2 )]
→
P [(if False then e1 else e2 )] →
P [e1 ]
P [e2 ]
17
Praktische Informatik 1, WS 2004/05, Kapitel 2, vom 27. Oktober 2004
Diese Regeln für if-then-else können nur angewendet werden, wenn der
Bedingungsausdruck zu True oder False ausgewertet ist.
Wir nennen eine Transformation auch Reduktion und eine Folge von Programmtransformationen auch Reduktionsfolge (oder Auswertung).
Beachte, dass diese Reduktionen / Transformationen überall im Ausdruck
gemacht werden dürfen. Erst wenn wir eine Strategie festlegen, ist die Auswertung festgelegt.
Bemerkung 2.2.9 Einschub: Sprechweise zu Programmen, Ausdrücken, und
was Meta-Notation bedeutet.
Man muss unterscheiden lernen, wann in Lehrbüchern, Skripten, Artikeln usw.
die konkrete Syntax im Text steht, und wann nur eine Meta-Notation.
Z.B. wenn wir sagen die Funktion fakultaet . . .“, dann ist auch diese Funk”
tion gemeint, syntaktisch steht im Text das Gleiche wie im Programm.
Wenn wir schreiben: Sei f eine Funktion in Programm . . .“, dann ist f eine
”
Meta-Variable, und nicht der Name einer Funktion.
Oben bei der Definitionseinsetzungsregel hatten wir geschrieben:
(Rumpff [t1 /x1 , . . . tn /xn ])
Hierbei sind alles Metavariablen:
f
Rumpff
t1 , . . . , t n
x1 , . . . , xn
steht für eine Funktion
steht für den Rumpf dieser Funktion
stehen für Ausdrücke: die Argumente
stehen für die formalen Parameter der Funktion
Die formalen Parameter können durchaus andere Namen als x1 , x2 , . . . haben: z.B. die Funktion gutgenug hat zwei formale Parameter: schaetzwert und
x.
Beispiel 2.2.10 Die
Auswertung
von
main
mit
der
Definition
main = wurzel 2.0 ist eine Folge von Transformationen. Das Programm
besteht aus den Funktionen wie oben für die Wurzelberechnung. Im folgenden ist
nur die Transformation auf dem Ausdruck für main angegeben. Die verwendete
Regel ist manchmal mit angegeben.
wurzel 2.0
(Definitionseinsetzung)
if not (2.0 + 0.1 > 2.0)
then error "wurzel: not a number"
else if 2.0 >= 0 then wurzeliter 1.0 2.0
else error "wurzel: Eingabe negativ"
(Auswertung arithmetischer Ausdruck)
if not (2.1 > 2.0)
then error "wurzel: not a number"
else if 2.0 >= 0 then wurzeliter 1.0 2.0
else error "wurzel: Eingabe negativ"
18
Praktische Informatik 1, WS 2004/05, Kapitel 2, vom 27. Oktober 2004
(Auswertung arithmetischer Ausdruck)
if not True then error "wurzel: not a number"
else if 2.0 >= 0 then wurzeliter 1.0 2.0
else error "wurzel: Eingabe negativ"
(Auswertung Boolescher Ausdruck)
if False
then error "wurzel: not a number"
else if 2.0 >= 0 then wurzeliter 1.0 2.0
else error "wurzel: Eingabe negativ"
(if-Auswertung)
if 2.0 >= 0 then wurzeliter 1.0 2.0
else error "wurzel: Eingabe negativ"
if True then wurzeliter 1.0 2.0
else error "wurzel: Eingabe negativ"
wurzeliter 1.0 2.0
if
gutgenug 1.0 2.0 then 1.0
else wurzeliter (verbessern 1.0 2.0)
2.0
(Definitionseinsetzung gutgenug )
if
abs(((quadrat 1.0) - 2.0)) / 2.0 < 0.00001
else wurzeliter (verbessern 1.0 2.0) 2.0
if
abs(((1.0 * 1.0) - 2.0)) / 2.0 < 0.00001 then 1.0
else wurzeliter (verbessern 1.0 2.0) 2.0
if
abs((1.0 - 2.0)) / 2.0 < 0.00001 then 1.0
else wurzeliter (verbessern 1.0 2.0) 2.0
if
abs((-1.0)) / 2.0 < 0.00001 then 1.0
else wurzeliter (verbessern 1.0 2.0) 2.0
if
1.0 / 2.0 < 0.00001 then 1.0
else wurzeliter (verbessern 1.0 2.0)
2.0
0.5 < 0.00001 then 1.0
else wurzeliter (verbessern 1.0 2.0)
2.0
if
if
False
then 1.0
then 1.0
19
Praktische Informatik 1, WS 2004/05, Kapitel 2, vom 27. Oktober 2004
else wurzeliter (verbessern 1.0 2.0)
wurzeliter (verbessern 1.0 2.0)
if
2.0
2.0
gutgenug (verbessern 1.0 2.0) 2.0 then (verbessern 1.0 2.0)
else wurzeliter (verbessern (verbessern 1.0 2.0) 2.0) 2.0
......
Offenbar gibt es Situationen, in denen man an verschiedenen Stellen eines
Ausdrucks die Ersetzungsregeln anwenden kann.
Beispiel 2.2.11 Für den Ausdruck quadrat(4 + 5) gibt es 3 verschiedene Reduktionsfolgen.
1. quadrat(4 + 5) → (4 + 5) ∗ (4 + 5) → 9 ∗ (4 + 5) → 9 ∗ 9 → 81
2. quadrat(4 + 5) → (4 + 5) ∗ (4 + 5) → (4 + 5) ∗ 9 → 9 ∗ 9 → 81
3. quadrat(4 + 5) → (quadrat 9) → 9 ∗ 9 → 81
Man sieht, dass das Ergebnis jedesmal das gleiche ist. Allerdings ist
die Anzahl der Transformationsschritte (Reduktionen) verschieden. Folgende
beruhigende Aussage gilt für das Ergebnis:
Satz 2.2.12 (Church-Rosser-1) Wenn für ein einfaches Haskell-Programm P
startend mit dem Ausdruck main zwei verschiedene Reduktionsfolgen mit den
jeweiligen Resultat-Basiswerten e1 bzw. e2 terminieren, dann sind diese Basiswerte gleich, d.h. e1 = e2 .
Damit können wir definieren:
Definition 2.2.13 Sei P ein Programm und main von numerischem Typ.
• Wenn es eine terminierende Reduktionsfolge, ausgehend von main gibt,
die mit e endet, dann ist e der Wert des Programms P .
• Wenn es keine terminierende Reduktionsfolge ausgehend von main gibt,
dann ist der Wert undefiniert (⊥); 3
Aus dem Satz von Church-Rosser folgt, dass ein einfaches Haskell-Programm
ein eindeutig definiertes Resultat hat. Möglicherweise ist das Resultat auch undefiniert, wenn man den Ausdruck nicht in ein Resultat überführen kann.
Die eingebauten Funktionen wie ∗, +, − verlangen, dass ihre Argumente als
Basiswerte vorliegen. Man sagt auch, diese Funktionen sind strikt.
3 Manchmal
verwendet man ⊥ als undefinierten Wert.
Praktische Informatik 1, WS 2004/05, Kapitel 2, vom 27. Oktober 2004
20
Es gibt zwei wichtige Reduktionsstrategien, die jeweils eindeutige Transformationen bestimmen. Ein Kurzcharakterisierung ist:
Normale Reihenfolge:
δ-Reduktion vor Argumentauswertung
Applikative Reihenfolge:
Argumentauswertung vor δ-Reduktion
Informelle Beschreibung:
• applikative Reihenfolge: Der Ausdruck t0 ist auszuwerten: Folgende Fälle
sind zu betrachten:
– Der Ausdruck ist bereits ein Basiswert. Dann fertig.
– Der Ausdruck ist eine Anwendung s t und s ist kein Funktionsname
und keine Anwendung. Dann wende applikative Reihenfolge auf s an.
– Der Ausdruck ist eine Anwendung f t1 . . . tn und f ein Funktionsname. Sei m die Stelligkeit von f . Wenn m ≤ n, dann wende die
applikative Reihenfolge der Auswertung auf den ersten Ausdruck ti
an, mit i ≤ m, der noch kein Basiswert ist. Wenn alle ti für i ≤ m Basiswerte sind, dann benutze δ-Reduktion. Wenn n < m, dann keine
Reduktion.
– Der Ausdruck ist if b then e1 else e2 . Wenn b ein Basiswert ist,
dann wende if-Reduktion an. Wenn b kein Basiswert, dann wende
applikative Reihenfolge der Auswertung auf b an.
• normale Reihenfolge der Auswertung: Man versucht, den Ausdruck t0 zu
transformieren.
– Der Ausdruck ist bereits ein Basiswert. Dann fertig.
– Der Ausdruck ist eine Anwendung s t und s ist kein Funktionsname
und keine Anwendung. Dann wende normale Reihenfolge auf s an.
– Der Ausdruck ist eine Anwendung f t1 . . . tn und f ist ein Funktionsname. Sei m die Stelligkeit von f . Wenn m ≤ n, dann benutze
δ-Reduktion. Ansonsten keine Reduktion.
– Der Ausdruck ist ein if b then e1 else e2 . Wenn b ein Basiswert ist,
dann wende if-Reduktion an. Wenn b kein Basiswert, dann wende
normale Reihenfolge der Auswertung auf b an.
– Der Ausdruck ist ein arithmetischer Ausdruck e1 op e2 (bzw. op e),
so dass Stelligkeit von op 2 ist (bzw. 1 für op e). Wende von links
nach rechts die normale Reihenfolge der Auswertung an.
Praktische Informatik 1, WS 2004/05, Kapitel 2, vom 2.11.2004
21
Die normale Reihenfolge der Auswertung hat zu beachten, dass es Funktionsanwendungen gibt, bei denen die Definitionsersetzung nicht durchführbar ist,
da bestimmte (eingebaute) Funktionen ihre Argumente in einer ausgewerteten
Form erwarten.
In Beispiel 2.2.11 kann man die Auswertungen jetzt klassifizieren.
Die erste Reduktionsfolge ist in normaler Reihenfolge, Die dritte Reduktionsfolge ist in applikativer Reihenfolge. Allerdings sieht man, dass es auch andere
Reduktionsfolgen gibt.
Die applikative Reihenfolge ist die call by value“-Variante, während die normale
”
Reihenfolge der entfernt der call by name“-Auswertung entspricht.
”
In prozeduralen, imperativen Programmiersprachen nennt man call by value“
”
eine Strategie des Prozeduraufrufs, die nur den Wert an die formalen Parameter
übergibt. Die Strategie call by name“ übergibt den Namen, so dass dieser in
”
der Prozedur selbst manipuliert werden kann. D.h. meist, dass eine Zuweisung
an diesen Namen das Ende der Prozedur überdauert (siehe auch Auswertung
von Python-Programmen im entsprechenden Kapitel).
Das Typsystem und die zugehörige Typüberprüfung von Haskell4 sorgen
dafür, dass Ausdrücke, die einen arithmetischen Typ haben bei Auswertung ein
entsprechendes Resultat liefern, falls die Auswertung ohne Fehler terminiert.
Satz 2.2.14 (Church-Rosser-2) Sei P ein einfaches Haskell-Programm. Wenn
es irgendeine Reduktionsfolge für main gibt, die mit einem Basiswert e terminiert, dann terminiert auch die normale Reihenfolge der Auswertung von main
und liefert als Resultat genau e.
Damit haben wir einen weiteren wichtigen Baustein: Wenn wir eine Menge
von Funktionsdefinitionen haben, und ein Ausdruck lässt sich zu einem Resultat
auswerten, dann kann man auch eine feste Strategie wählen, um dieses Resultat
zu berechnen. D.h. jetzt haben wir eine eindeutige (deterministische) Berechnungsvorschrift, die zu einem Ausdruck dessen Wert berechnet. Damit gilt:
Eine Menge von Funktionsdefinitionen, ein Ausdruck und die Festlegung auf eine ausführbare Auswertungsstrategie ergibt eine Berechnungsvorschrift.
Im allgemeinen wählen wir die normale Reihenfolge der Auswertung.
Die Sätze von Church-Rosser geben uns die Freiheit, auch dann von einem Algorithmus zu sprechen, wenn die Reihenfolge der Auswertung nicht festgelegt
ist, und es uns nur wichtig ist, dass es ein eindeutiges Resultat gibt.
Beispiel 2.2.15 Der Satz von Church-Rosser-2 gilt nicht für die applikative
Reihenfolge der Auswertung:
nt x
= nt x
proj x y = x
4 Typen
werden wir noch behandeln
Praktische Informatik 1, WS 2004/05, Kapitel 2, vom 2.11.2004
22
Der Ausdruck proj 0 (nt 1) hat unter der applikativen Reihenfolge der Auswertung kein Resultat, denn diese Auswertung terminiert nicht, weil zuerst versucht wird die Argumente auszuwerten:
(nt 1) → (nt 1) → (nt 1) . . .
Die normale Reihenfolge der Auswertung, die zuerst die Definitionseinsetzung
verwendet, liefert sofort das Resultat 0.
Eine optimale5 Zahl von Reduktionen bei Transformationen wird erreicht,
wenn man bei der normalen Reihenfolge darauf achtet, welche Unterausdrücke
kopiert wurden, deren Transformationen parallel macht, und die parallel ausgeführten Reduktionen nur als eine Reduktion zählt. In Implementierungen wird
dies dadurch erreicht, dass keine Kopie eines (auswertbaren) Ausdrucks gemacht
wird, sondern intern ein gerichteter Graph statt eines Baumes erzeugt wird. Diese Auswertungsstrategie wird von Haskell verwendet. Sie wird verzögerte Auswertung oder auch lazy evaluation genannt. Der Satz von Church-Rosser-2 gilt
auch noch für diese Graphvariante der normalen Reihenfolge, d.h. für verzögerte
Auswertung.
Inn einfachen Haskell-Programmen gilt:
Aussage 2.2.16 Die verzögerte Reihenfolge der Auswertung ergibt eine optimale Anzahl von Reduktionen.
Im Beispiel quadrat(4 + 5) sieht die Reduktion folgendermaßen aus:
Beispiel 2.2.17 quadrat(4 + 5) → (4 + 5)(1) ∗ (4 + 5)(1) → 9 ∗ 9 → 81, wobei
mit (4 + 5)(1) angedeutet werden soll, dass dies intern derselbe Ausdruck ist.
Ein weiterer Vorteil der verzögerten Auswertung besteht darin, dass Programmtransformationen die zur Compilezeit mittels verzögerter Auswertung
durchgefhrt werden, korrekt sind im Sinne der operationalen Semantik. Das
gilt nicht für die applikative Reihenfolge der Auswertung, d.h. auch nicht für
Programmiersprachen, die die applikative Reihenfolge der Auswertung verwenden.
2.3
Rekursive Auswertung in Haskell
Wir haben in Beispielen schon rekursive Funktionen definiert und ausgewertet.
In diesem Abschnitt sollen einige unterschiedliche Formen der Auswertungsprozesse, die durch Rekursion bewirkt werden, klassifiziert werden. Zur Vereinfachung und Analyse wird eine feste rekursive Funktion f betrachtet und die
Auswertung entsprechend analysiert. Ein Unterausdruck (f t1 . . . tn ) im Rumpf
von f wird manchmal auch als rekursiver Aufruf bezeichnet.
Für die folgenden Betrachtungen benötigen wir Funktionen mit einer speziellen Auswertungsreihenfolge, die man selbst definieren kann.
Eine Funktion f nennt man applikativ, wenn sie (bei der normalen Reihenfolge
5 unter
den bisherigen Voraussetzungen
Praktische Informatik 1, WS 2004/05, Kapitel 2, vom 2.11.2004
23
der Auswertung) zuerst die Argumente von links nach rechts auswertet, und
dann erst den eigentlichen Rumpf auswertet.
Eine Funktion f nennt man strikt, wenn die Argumente im Laufe der Auswertung des Rumpfs auf jeden Fall ausgewertet werden. Hierbei ist die Reihenfolge
nicht festgelegt.
Es gilt: applikativ ⇒ strikt.
Man kann zu jeder Funktion f eine applikative Variante f_applikativ definieren, die zuerst die Argumente auswertet. Es gilt aber i.a., dass die Funktionen
f und f_applikativ semantisch verschieden sind.
Die Fakultätsfunktion mit der mathematischen Definition
0! := 1
n! := n ∗ (n − 1)!
haben wir schon in Haskell definiert:
fakultaet x
= if x <= 1 then 1
else x*(fakultaet (x-1))
Die Auswertung können wir veranschaulichen, wenn wir die Folge der Transformationen betrachten, die zum Ergebnis führt, einige Zwischenschritte auslassen, und nur den Ausdruck in dem Moment zeigen, wenn der nächste rekursive
Aufruf gemacht wird. In der Sprache der Transformationen: wenn der nächste
Ausdruck fakultaet n transformiert wird. Wir nehmen dafür die normale Reihenfolge.
(fakultaet 6)
(6 * (fakultaet (6-1)))
(6 * (5 * (fakultaet (5-1))))
(6 * (5 * (4 * (fakultaet (4-1)))))
(6 * (5 * (4 * (3 * (fakultaet (3-1))))))
(6 * (5 * (4 * (3 * (2 * (fakultaet (2-1)))))))
(6 * (5 * (4 * (3 * (2 * 1)))))
(6 * (5 * (4 * (3 * 2))))
(6 * (5 * (4 * 6)))
(6 * (5 * 24))
(6 * 120)
720
Dieses Verhalten eines Auswertungsprozesses nennt man auch linear rekursiv.
Charakteristisch ist, dass jeweils nur eine rekursive Funktionsanwendung
(der betrachteten Funktion) auftritt, der Gesamtausdruck aber beliebig groß
werden kann.
Eine alternative Berechnung der Fakultätsfunktion erhält man, wenn man
folgende Ersetzung iteriert, und dann entsprechend programmiert.
24
Praktische Informatik 1, WS 2004/05, Kapitel 2, vom 2.11.2004
Produkt
Zähler
⇒
⇒
Produkt ∗ Zähler
Zähler + 1
fakultaet_iter n =
fakt_iter
1 1 n
fakt_iter produkt zaehler max =
if zaehler > max
then produkt
else fakt_iter (zaehler * produkt)
(zaehler + 1) max
akt_iter_strikt produkt zaehler max =
strikt_3 produkt zaehler max
(if zaehler > max
then produkt
else fakt_iter_strikt (zaehler * produkt)
fakultaet_lin n =
(zaehler + 1) max)
fakt_iter_strikt 1 1 n
Die Funktion fakt iter strikt ist die strikte Variante der Funktion
fakt iter. Der Auswertungsprozess sieht folgendermaßen aus, wenn man einige Zwischenschritte weglässt:
(fakultaet_lin 6)
(fakt_iter_strikt
(fakt_iter_strikt
(fakt_iter_strikt
(fakt_iter_strikt
(fakt_iter_strikt
(fakt_iter_strikt
(fakt_iter_strikt
720
1 1 6)
1 2 6)
2 3 6)
6 4 6)
24 5 6)
120 6 6)
720 7 6)
Diese Art eines Auswertungs-Prozesses nennt man end-rekursiv (tail recursive):
Genauer: wenn die Auswertung eines Ausdrucks (f a1 . . . an ) nach einigen
Auswertungen als Resultat den Wert eines Ausdruck der Form (f b1 . . . bn )
auswerten muss; hierbei können die Argumente ai , bi Ausdrücke sein. Dies muss
für die gesamte Rekursion gelten.
Charakteristisch ist, dass man als Zwischenaufgabe nur hat, einen Aufruf
von (f b1 . . . bn ) auszuwerten, und am Ende nur den Wert des letzten rekursiven
Ausdrucks braucht. Es ist nicht nötig, die Rekursion rückwärts wieder aufzurollen und den Wert an den ersten Aufruf zurückzugeben, da keine Berechnungen
mehr stattfinden.
Endrekursion in imperativen Programmiersprachen ist normalerweise nicht
optimiert, so dass man bei einer Rekursion der Tiefe n am Ende n Rückgabeschritte hat und auch entsprechend viel Platz benötigt. Bei optimierter Endrekursion spricht man auch von einem iterativen Prozess bzw. von Iteration. Genauer: wenn die Auswertung eines Ausdrucks (f a1 . . . an ) nach einigen
Praktische Informatik 1, WS 2004/05, Kapitel 2, vom 2.11.2004
25
Auswertungen als Wert einen Ausdruck der Form (f b1 . . . bn ) auswerten muss.
Hierbei müssen die Argumente ai , bi jeweils Basiswerte sein. Dies muss für die
gesamte Rekursion gelten. Der obige Auswertungsprozess ist somit iterativ.
In imperativen Programmiersprachen gibt es im allgemeinen mehrere Programmkonstrukte, mit denen man Iteration ausdrücken kann: for ...do,
while, oder repeat ...until. Diese sind im Falle einer fehlenden Optimierung
der Endrekursion auch notwendig, damit man effizient eine Iteration programmieren kann.
Bei der Verwendung von Haskell ist zu beachten, dass die verzögerte Auswertung verwendet wird, die zwar höchstens soviele Reduktionsschritte wie die
applikative Reihenfolge benötigt, aber ohne die richtigen Striktheitsanweisung
oft einen linear rekursiven Prozess erzeugt statt eines iterativen Prozesses.
Beispiel 2.3.1 Die Auswertung des Ausdrucks (fakultaet\_iter 5) hat bei
verzögerter Auswertung folgende Zwischenstufen.
(fakultaet_iter 5)
(fakt_iter 1 1 5)
(fakt_iter (1*1) (1+1) 5)
(fakt_iter (2*(1*1)) (2+1) 5)
(fakt_iter (3*(2*(1*1))) (3+1) 5)
(fakt_iter (4*(3*(2*(1*1)))) (4+1) 5)
(fakt_iter (5*(4*(3*(2*(1*1))))) (5+1) 5)
(5*(4*(3*(2*(1*1)))))
120
Das ist eine lineare Rekursion, es ist auch eine Endrekursion, aber die Rekursion ist nicht iterativ, da die Argumente wachsen und auch keine Basiswerte
sind.
2.3.1
Baumrekursion
Wir nehmen als Beispiel dafür die Berechnung der Fibonacci-Zahlen, auch wenn
diese Berechnung effizienter programmiert werden kann. Baumrekursion entspricht den primitiv rekursiven Funktionen.
Beispiel 2.3.2 Berechnung der Fibonacci Zahlen :
1, 1, 2, 3, 5, 8, 13, 21, . . .
Die Fibonacci Zahlen sind definiert durch folgende rekursive Gleichung:

falls n = 0
 0
1
falls n = 1
F ib(n) :=

F ib(n − 1) + F ib(n − 2) sonst
fib n = if n <= 0
else if n
else
fibs n = strikt_1
then 0
== 1 then 1
fib (n-1) + fib(n-2)
fib
26
Praktische Informatik 1, WS 2004/05, Kapitel 2, vom 4.11.2004
Der induzierte Auswertungsprozess ergibt folgende Ausdrücke, wobei wir mit
fibs die strikte Variante von fib benutzen
fibs 5
fibs 4 + fibs 3
(fibs 3 + fibs 2) + fibs 3
((fibs 2 + fibs 1) + fibs 2) + fibs 3
(((fibs 1 + fib 0) + fibs 1) + fibs 2)
(((1+0) + fibs 1) + fibs 2) + fibs 3
((1 + fibs 1) + fibs 2) + fibs 3
((1+1) + fibs 2) + fibs 3
(2 + fibs 2) + fibs 3
(2 + (fibs 1 + fibs 0)) + fibs 3
.......
+ fibs 3
5
4
3
2
3
1
2
2
0
1
1
1
0
0
1
Einen solchen Auswertungsprozess nennt man auch Baumrekursion. Charakteristisch ist, dass die Ausdrücke unbegrenzt wachsen können und dass mehrere
rekursive Aufrufe vorkommen. Diese sollten nicht geschachtelt sein, d.h. in den
Argumenten einer rekursiven Funktion sollten keine rekursiven Funktionsaufrufe
mehr vorkommen.
Bemerkung 2.3.3 Geschachtelte Baumrekursion
Der allgemeine Fall ist geschachtelte Baumrekursion, obwohl er normalerweise
selten benötigt wird, denn die damit definierten Funktionen sind i.a. nicht effizient berechenbar. Ein Beispiel ist die Ackermannfunktion, die folgendermaßen
definiert ist:
----ack 0
ack 1
ack x
ack x
y
0
0
y
Ackermanns Funktion ---= 1
= 2
| x >= 2 = x+2
| x > 0 && y > 0 = ack (ack (x-1) y) (y-1)
27
Praktische Informatik 1, WS 2004/05, Kapitel 2, vom 4.11.2004
Hier wird eine spezielle Syntax von Haskell verwendet:
Von oben nach unten wird probiert, welche Definitionsgleichung (links vom
Gleichheitszeichen) passt:
1) Argumente anpassen, evtl. auswerten
2) Bedingung rechts vom | prüfen
----ackopt
ackopt
ackopt
ackopt
ackopt
ackopt
Ackermanns Funktion optimiert ---0 y = 1
1 0 = 2
x 0 = x+2
x 1 = 2*x
x 2 = 2^x
x y | x > 0 && y > 0 = ackopt (ackopt (x-1) y) (y-1)
-- (ackopt 5 3) = ???
*Main> logI10 (ackopt 5 3)
19728.301029995662
--(Anzahl DezimalStellen)
-- ( == 2^65536)
2...
Der Aufruf (ack 4 4) ist ein Exponentialterm 22
der Höhe 65536. Diese
Zahl kann man nicht mehr in Ziffern berechnen.
Diese Funktion wächst sehr schnell und benötigt sehr viele Ressourcen zur
Berechnung. Sie wurde konstruiert, um nachzuweisen, dass es sehr schnell wachsende Funktionen gibt, die nicht primitiv rekursiv sind, d.h. nicht mit einem speziellen Rekursions-Schema definiert werden können. 6 Sie hat auch Anwendung
in der theoretischen Informatik (Komplexitätstheorie).
Tabellarische Darstellung der verschiedenen rekursiven Prozesse.
linear rekursiv
endrekursiv
iterativ
Baumrekursion
geschachtelte
Baumrekursion
2.4
maximal ein rekursiver Unterausdruck
linear rekursiv und Gesamtresultat ist Wert des rekursiven
Unterausdrucks
endrekursiv und Argumente sind Basiswerte
mehrere rekursive Unterausdruck, Argument des rekursiven
Ausdrucks ohne weitere Rekursion
mehrere rekursive Unterausdrücke auch in den Argumenten
Analyse von Algorithmen
Wir analysieren Algorithmen hinsichtlich ihres Ressourcenbedarfs, insbesondere
indem wir die Anzahl der Reduktionen zählen, bzw die Größe der Ausdrücke
6 Die Einschränkung kann man informell so beschreiben: es ist eine lineare Rekursion, in der
man zur Wertberechnung nur Addition und als Konstante nur die 1 verwenden darf. Man darf
auch Zählervariablen verwenden, und man darf primitiv rekursiv definierte Funktionen wiederverwenden. Addition, Multiplikation, Potenz, Fibonacci usw, können alle primitiv rekursiv
definiert werden.
Praktische Informatik 1, WS 2004/05, Kapitel 2, vom 4.11.2004
28
messen, die während der Transformation (Auswertung) entstehen. Am Beispiel
der Fibonacci-Zahlen kann man dann nachweisen, dass es Fälle gibt, in denen
eine iterativ programmierte Funktion weniger Reduktionen benötigt (d.h. effizienter ist) als eine rekursiv geschriebene.
Beispiel 2.4.1 Die Berechnung von fib 5 ist leicht zu analysieren:
fib 3 wird 2 mal berechnet
fib 2 wird 3 mal berechnet
fib 1 wird 5 mal berechnet
Genauer: Die Analyse der Berechnung von fib n für n ≥ 2 zeigt, dass fib 1
jeweils fib n-mal berechnet wird.
√
n
√ wobei Φ = 1+ 5 ≈ 1.6180
Es gilt, dass fib n ≈ Φ
2
5
D.h. der Wert von fib wächst exponentiell. Die Anzahl der Reduktionen ist
ebenfalls exponentiell abhängig von n, d.h.
die Laufzeit der Funktion fib ist exponentiell abhängig von n.
Der Ausdruck selbst wächst nicht so schnell: Die Größe ist eine lineare Funktion in n, wenn man einige Reduktionen in applikativer Reihenfolge macht.
2.4.1
Iterative Version von Fib
Es gibt eine einfache Idee, die Berechnung von fib zu beschleunigen. Eine Beobachtung dazu ist, dass man zur Berechnung von fib(n) höchstens die Werte
fib(i) benötigt für alle 1 ≤ i ≤ n. D.h. die sukzessive Berechnung einer Wertetabelle ergibt bereits einen Algorithmus, der schneller ist als fib. Eine einfache,
verbesserte Variante ist die Idee, sich jeweils nur fib(n − 1) und fib(n − 2) zu
merken und daraus den nächsten Wert fib(n) zu berechnen.
Rechenvorschrift: (a, b) → (a + b, a)
fib_lin n = (fib_iter_strikt 1 0 n)
-Idee:
fib_iter FIB(i) FIB(i-1) n
fib_iter a b zaehler =
if zaehler <= 0
then b
else fib_iter (a + b) a (zaehler - 1)
fib_iter_strikt a b zaehler =
strikt_3 a b zaehler
(if zaehler <= 0
then b
else fib_iter_strikt (a + b) a (zaehler - 1))
Prozess für (fib lin 5):
(fib_lin 5)
(fib_iter_strikt
1 0 5)
Praktische Informatik 1, WS 2004/05, Kapitel 2, vom 4.11.2004
(fib_iter_strikt
(fib_iter_strikt
(fib_iter_strikt
(fib_iter_strikt
5
1
2
3
5
1
1
2
3
29
4)
3)
2)
1)
Offenbar benötigt diese Version von fib linear viele Reduktionen in
Abhängigkeit von n, und die Größe aller Ausdrücke, die während des Prozesses
erzeugt werden, hat ein obere Schranke, also ist der Platzbedarf konstant (d.h.
unabhängig) von n. Allerdings nur, wenn man die Größe der Darstellungen der
Zahlen vernachlässigt.
D.h. dieser Prozess ist somit iterativ.
2.4.2
Terminierung: Hält ein Algorithmus für alle Eingaben an?
Beispiel 2.4.2 Folgende Funktion ist leicht zu definieren, aber deren Terminierung für alle natürlichen Zahlen ist unbekannt. Diese Funktion ist unter dem
Namen (3n + 1)-Funktion bekannt. Die Collatz Vermutung“ sagt, dass diese
”
Funktion für jede natürliche Zahl als Eingabe anhält.
drein x = if x == 1 then 1
else if even x then drein (x ‘div‘ 2)
else drein (3*x+1)
Die Ausführung ergibt z.B für 5 : 5, 16, 8, 4, 2, 1
für 27:
27, 82, 41, 124, 62, 31, 94, 47, 142, 71, 214, 107, 322, 161, 484, 242, 121, 364, 182, 91,
274, 137, 412, 206, 103, 310, 155, 466, 233, 700, 350, 175, 526, 263, 790, 395, 1186,
593, 1780, 890, 445, 1336, 668, 334, 167, 502, 251, 754, 377, 1132, 566, 283, 850,
425, 1276, 638, 319, 958, 479, 1438, 719, 2158, 1079, 3238, 1619, 4858, 2429, 7288, 3
644, 1822, 911, 2734, 1367, 4102, 2051, 6154, 3077, 9232, 4616, 2308, 1154, 577, 1732,
866, 433, 1300, 650, 325, 976, 488, 244, 122, 61, 184, 92, 46, 23, 70, 35, 106, 53, 160,
80, 40, 20, 10, 5, 16, 8, 4, 2, 1
Die Terminierung dieser Funktion drein wurde bereits überprüft für alle
Zahlen ≤ 240 .
Die Frage, ob eine Funktion für eine Menge von Eingaben anhält, sollte man
sich beim Algorithmenentwurf stellen, denn sonst ist für einige Eingaben kein
Resultat definiert. Normalerweise entwirft man (einfache) Algorithmen gleich
so, dass sie auf allen Eingaben auch anhalten.
Leider zeigt die Theorie, dass eine Programmiersprache, die von vorneherein
nur Programme zulässt, die für alle ihre Eingaben terminieren, nicht ausreicht
zur Formalisierung aller Berechnungsverfahren. Somit muss man damit leben,
dass es Programme geben kann, die manchmal nicht anhalten.
Praktische Informatik 1, WS 2004/05, Kapitel 2, vom 4.11.2004
30
Terminierungsnachweise führt man normalerweise mit vollständiger Induktion bzw. mit einer fundierten Ordnung 7 . In einer funktionalen Sprache
lässt sich das besonders einfach hinschreiben.
Beispiel 2.4.3
fib n = if n <= 0 then 0
else if n == 1 then 1
else fib (n-1) + fib(n-2)
Behauptung: fib terminiert für jede Eingabe einer natürlichen Zahl mit
einer ganzen Zahl:
Induktionsbasis ist 0 und 1: fib(0) und fib(1) terminieren und ergeben jeweils das Resultat 1.
Induktionsschritt: Für n > 1 gilt:
fib(n) reduziert zu fib(n−1)+fib(n−2). Da n−1 und n−2 im Bereich der
Induktionshypothese liegen, d.h. 0 ≤ n − 1 ≤ n und 0 ≤ n − 2 ≤ n terminieren
diese Aufrufe mit einer Zahl. Anschließende Addition terminiert ebenfalls, so
dass der Rumpf einschließlich der rekursiven Aufrufe terminiert und ein Wert
berechnet wird.
Damit kann man vollständige Induktion verwenden und erhält, dass die
Funktion fib für alle Eingaben terminiert
Beispiel 2.4.4 Die einfachen Ansätze zum Terminierungsbeweis für die 3n+1Funktion scheitern, da die Argumente unregelmäßig größer und kleiner werden.
Offenbar kann man Terminierung leicht zeigen für Zahlen der Form 2n , aber
bereits für (drein 3) ist man auf Ausprobieren angewiesen.
2.4.3
Ressourcenbedarf von Algorithmen,
Wachstumsraten und Größenordnungen
Effizienz,
Wir untersuchen den Ressourcenbedarf von Algorithmen. Die wichtigsten Ressourcen sind:
• Zeit
• Platz
Es gibt weitere Ressourcen / Maße, die für Algorithmen, Programme und deren
Anwendung eine Rolle spielen können:
• Anzahl benötigter Prozessoren bei parallelen Algorithmen,
• Größe des Programms
• Kosten für die Kommunikation
7 Siehe Vollständige Induktion“ im Skript Einige mathematische Grundlagen für Infor”
”
matiker“
31
Praktische Informatik 1, WS 2004/05, Kapitel 2, vom 4.11.2004
• Anzahl der Aufrufe bestimmter Unterprozeduren (Dienste): Datenbankzugriffe, usw.
In der Anwendung können auch andere Kosten eine Rolle spielen. Beispiele sind:
• Aufwand zur Erstellung des Programms
• Aufwand zum
)Umgebungen.
Portieren
eines
Programms
in
andere
(Rechner-
• Aufwand für Verifikation / Änderung eines Programms
• Kosten innerhalb eines organisatorischen Rahmens
Alle hier erwähnten Ressourcen spielen in der Praxis eine Rolle. Da die
Rechnerentwicklung ständig schnellere Prozessoren und leistungsfähigere Speicherhardware entwickelt, sind die zu optimierenden Ressourcen nicht immer
Zeit und Platz. Ein Beispiel sind graphische Benutzeroberflächen, oder Übertragung und Bearbeitung von Bildern, Videos o.ä. statt Zeichen, was einen großen
Platzbedarf bedeutet. Als prominentes und aktuelles Beispiel kann man Java
nennen, bei dem die übliche Vorgehensweise ist, dass man ein interpretierbaren
File (Bytecode) von anderen Rechnern lädt und diesen dann von einem Interpreter ausführen lässt. Hier ist die Ausführungszeit des Algorithmus selbst meist
nicht die bestimmende Größe, sondern die Größe des Bytecodes und die leichte Ausführbarkeit auf verschiedensten Rechnern steht im Vordergrund, danach
kommt der Gesamtbedarf an Zeit (inklusive Übertragungszeit), und danach erst
der Zeitbedarf des Algorithmus selbst.
Bemerkung 2.4.5 Die Optimierung (Tuning) von Programmen hat das Ziel,
durch Abänderungen des Programms bzw. verschiedene Kompilierungen des Programms den Ressourcenbedarf zu reduzieren. Man sollte folgendes beachten:
• Wie oft wird dieses Programm benötigt? Lohnt sich der Optimieraufwand
/ Kosten im Vergleich zur Gesamtlaufzeit des Programms?
• Das Programm wird oft durch Änderungen zum Zwecke der Optimierung
unübersichtlicher, d.h. schlechter lesbar und wartbar.
• Wenn zu erwarten ist, dass mit späteren Versionen des benutzten Compilers (Interpreters) oder eines zugrundeliegenden Systems der Ressourcenbedarf verbessert wird, dann sollte man eher abwarten.
• Man sollte sich darüber im klaren sein, ob und welche Optimierungen die
Portierbarkeit verschlechtern
• Die experimentelle Feststellung des Ressourcenbedarfs von Algorithmen /
Programmen ist fehlerbehaftet. D.h. die experimentelle Feststellung, dass
ein bestimmtes Vorgehen den Algorithmus verbessert, kann in zukünftigen
Programmierumgebungen falsch sein.
32
Praktische Informatik 1, WS 2004/05, Kapitel 2, vom 4.11.2004
Wir betrachten zunächst mal nur die Ressourcen Zeit und Platz und benutzen diese Messlatte, um bestimmte Verfahren und Algorithmen zu analysieren.
Wir machen eine Vereinfachung und messen folgendes:
Zeit:
Anzahl der Transformationsschritte.
Platz: maximale Größe der Zwischen-Ausdrücke bei der Auswertung
Die arithmetischen und Booleschen Operationen zählen wir als jeweils einen
Transformationsschritt.
Beim Platzbedarf unterscheidet man generell noch zwischen
Platzbedarf der Eingabe:
Platzbedarf des Zwischenspeichers:
Größe der Argumente der Funktion
Maximale Größe der Ausdrücke
während der Auswertung.
Hierbei bleibt die Eingabe unberücksichtigt
Oft macht man dann nur Aussagen über diesen zusätzlichen Platzbedarf.
Die Angaben über Ressourcenverbrauch können für eine feste Eingabe gemacht werden, wir wollen aber auch Aussagen machen für alle möglichen Eingaben, die ein fester Algorithmus bearbeiten kann. Der Aufwand eines Algorithmus
wird deshalb angegeben als Funktion der Größe der Eingabe. Im allgemeinen
nimmt man als Größe der Eingabe die Größe der tatsächlich verwendeten Repräsentation. Für Algorithmen mit ganzzahliger positiver Eingabe ist als Bezugsgröße die Zahl selbst geeignet. Allerdings muss man dann bei der Angabe
der Aufwandsfunktionen diese Bezugsgröße angeben.
Der Ressourcenbedarf eines Algorithmus wird i.a. bzgl. eines abstrakten Maschinenmodells definiert. Meist wird als theoretische Bezugsgröße die Turingmaschine genommen; es kann aber auch ein andere abstraktes Maschinenmodell sein. Der Ressourcenbedarf wird dann bzgl. dieses Modells bestimmt. Bei
der Abschätzung des Zeitbedarfs durch die Anzahl der Reduktionsschritte muss
man sich vergewissern, dass dies korrekt ist bzw. wie groß der Fehler ist, den
man dabei macht. Für Haskell nehmen wir der Einfachheit halber an, dass eine einzelne Reduktion in konstanter Zeit durchgeführt werden kann, wenn die
Definition der Funktionen gegeben ist. Außerdem ist zu beachten, dass der Aufwand für die Reduktion gewisser eingebauter Funktionen unbekannt ist. Auch
hier macht man die vereinfachende Annahme, dass dieser Aufwand eine (kleine) obere Schranke hat. Bei Feinanalysen müsste man den genauen Zeitbedarf
aller eingebauten Funktionen (zumindest relativ) kennen (Z.B. Multiplikation,
Addition, Konversion von Zahlen,usw.).
Beim Zählen der Reduktionsschritte werden wir
die verzögerte Auswertung verwenden.
Im Normalfall rechnen wir nur mit ganzen Zahlen (Typ Int), die z.B. < 232
sind, in diesem Fall kann man deren Platzbedarf als konstant annehmen. Dies
33
Praktische Informatik 1, WS 2004/05, Kapitel 2, vom 10.11.2004
gilt z.B. auch für die üblichen Darstellungen von Gleitkommazahlen. In Sonderfällen muss man bei der Abschätzung des Platzbedarfs berücksichtigen, dass
der benötigte Platz für Zahlen nicht konstant ist, wenn man Zahlen beliebiger
Größe verwendet (Integer).
Der optimale Platzbedarf im Sinne der Größe der Ausdrücke ist nicht mit einer festen Strategie ermittelbar oder erreichbar. Streng genommen müsste man
über alle Reduktionen minimieren. Oft hat ein iterativer Prozess den besten
Platzbedarf. Wir werden zum Ermitteln des Platzbedarfs die verzögerte Auswertung verwenden, und evtl. Striktheits-Abänderungen der Reduktionsstrategie in
den Definitionen der Funktionen markieren.
Wir schreiben den Ressourcenbedarf eines Algorithmus alg als Funktion der
Eingabe von der Größe n:
redalg (n)
maximale Anzahl der Reduktionen der verzögerten Auswertung für eine Eingabe der Größe n
P latzalg (n) Platzbedarf: maximale Gre der Ausdrücke (der gerichteten
Graphen) der verzögerten Auswertung für eine Eingabe der
Größe n, wobei die Eingabeargumente nicht berücksichtigt
werden
Beispiel 2.4.6 Der Ressourcenbedarf des Algorithmus für fib wird gemessen
in der Größe der eingegebenen Zahl. Vereinfacht man weiter und zählt nur die
Anzahl der rekursiven Aufrufe von fib(n), so ergibt sich: Anzahl der Aufrufe
(inkl. des obersten Aufrufs)= 2 ∗ fib(n + 1) − 1. Da die Anzahl der nicht-fibReduktionen konstant ist, ergibt sich:
redfib (n) ≈ c ∗ fib(n + 1),
√
wobei c eine Konstante ist. Da fib(n) exponentiell wächst, ≈ (0.5∗( 5+1))n ≈
1.618n , gilt dies auch für den Zeitbedarf.
Misst man den Ressourcenbedarf allerdings in der Größe der Darstellung
der Zahl n, so sieht man: Die Darstellung benötigt dlog10 (n)e Dezimalstellen.
size(inp)
)
D.h. mit diesem Maß wäre der Zeitbedarf ≈ 1.618(10
, d.h. doppelt exponentiell.
Führt man die Analyse durch für fib lin, dann erhält man redfib
O(n)
lin (n)
=
Die Komplexitätstheorie untersucht die Eigenschaft von Problemen in Bezug
auf deren Ressourcenbedarf; Im wesentlichen sind dies Platz und Zeit. D.h. es
wird auch untersucht, ob Algorithmen für ein Problem optimal sind, oder ob es
theoretisch besser geht oder nicht.
Beispiel 2.4.7 Beispiel: Reduktionsanzahl für fakultaet
Für die in naiver Weise rekursiv definierte Funktion fakultaet berechnen
wir die Anzahl der Reduktionen bei verzögerter Auswertung.
Praktische Informatik 1, WS 2004/05, Kapitel 2, vom 10.11.2004
fakultaet x
34
= if x <= 1 then 1
else x*(fakultaet (x-1))
Zunächst mal für kleine Eingaben.
fakultaet 1
if 1 <= 1 then ...
if True then ...
1
Das ergibt 3 Reduktionsschritte für fakultaet 1.
fakultaet 2
if 2 <= 1 then ...
if False then ...
2*(fakultaet (2-1))
2*(if (2-1) <= 1 then ... )
2*(if 1 <= 1 then ... )
2*(if True then ... )
2* 1
2
Das sind 8 Reduktionsschritte für fakultaet 2.
fakultaet 3
if 3 <= 1 then ...
if False then ...
3*(fakultaet (3-1))
3*(if (3-1) <= 1 then ... )
3*(if 2 <= 1 then ... )
3*(if False then ... )
3*(2*fakultaet (2-1))
3*(2*(if 2-1 <= 1 then ...
3*(2*(if 1 <= 1 then ... ))
3*(2*(if True then ... ))
3*(2* 1)
3*2
6
Das sind 13 Reduktionsschritte für fakultaet 3.
Allgemein ergibt sich als Vermutung, dass
redfakultaet (n) = 5 ∗ (n − 1) + 3
Wir führen den Nachweis mit vollständiger Induktion:
Dazu zeigen wir zunächst die Behauptung:
fakultaet (n-1) (als Ausdruck) benötigt 5 ∗ (n − 2) + 4 Reduktionsschritte für
n≥2
35
Praktische Informatik 1, WS 2004/05, Kapitel 2, vom 10.11.2004
Die Induktions-Basis ist leicht: fakultaet (2-1) benötigt 4 Reduktionsschritte.
Beim Induktions-Schritt ist nachzuweisen dass fakultaet (n-1) für n > 2
5 ∗ (n − 2) + 4 Reduktionsschritte benötigt. Für kleinere Basiswerte als n kann
man dann diese Behauptung als Induktionshypothese verwenden, d.h. als bereits bewiesen annehmen. Wir reduzieren den Ausdruck fakultaet (n-1) und
berechnen die Anzahl der Reduktionsschritte:
fakultaet (n-1)
if (n-1) <= 1 then ...
if n1 <= 1 then ...
if False then ...
n1*fakultaet (n1-1)
-- n1 ist Basiswert > 1
Das sind 4Reduktionsschritte, 5 ∗ (n1 − 2) + 4 Reduktionsschritte nach Induktionshypothese, und 1 Reduktionsschritt für die Multiplikation. Zusammen sind
das 4 + 5 ∗ (n1 − 2) + 4 + 1 = 5 ∗ (n − 2) + 4 Reduktionsschritte. Damit ist die
Behauptung bewiesen.
Es fehlt noch die Berechnung für den Aufruf fakultaet n, wenn n bereits
ein Basiswert ist.
fakultaet n
if n <= 1 then ...
if False then ...
n*fakultaet (n-1)
Das sind unter Verwendung der oben gezeigten Anzahl Schritte: 3 + (5 ∗ (n −
2) + 4) + 1 = 5 ∗ (n − 1) + 3, d.h. Damit ist die Formel für fakultaet gezeigt.
Die Untersuchung von fib und fakultaet ergibt eine reguläreres Verhalten
als man es im allgemeinen hat: Viele Problemklassen verhalten sich irregulärer
als diese einfachen Berechnungen: Es kann ein breite Streuung der Anzahl der
Reduktionen geben für die Menge aller Eingaben einer bestimmten Größe. Zum
Beispiel hat die (3n + 1)-Funktion eine Streuung der Anzahl der Reduktionen,
wenn man alle Eingaben der Größe 3, d.h. alle dreistellige Zahlen betrachtet.
Deshalb untersucht man folgende Gütemaße von Algorithmen und gibt
auch oft nur Abschätzungen nach oben bzw. unten an bzw. asymptotische
Abschätzungen, d.h. für große Eingaben.
• Ressourcenbedarf im schlimmsten Fall (worst-case). Was ist die obere
Schranke des Ressourcenbedarfs (Zeit, Platz)?
Das ist die bisherige Definition, die das jeweilige Maximum betrachtet.
• Ressourcenbedarf im besten Fall (best-case). Was ist die untere Schranke
des Ressourcenbedarfs (Zeit, Platz)? Hierbei wird das jeweilige Minimum
betrachtet: minimale Anzahl der Reduktionen der verzögerten Auswertung für eine Eingabe der Größe n
Praktische Informatik 1, WS 2004/05, Kapitel 2, vom 10.11.2004
36
• Ressourcenbedarf im Mittel (average case). Was ist der Mittelwert des
Ressourcenbedarfs (Zeit, Platz)?.
Oft hat man eine natürliche stochastische Verteilung über der Menge der
möglichen Eingaben. Manchmal hat man die Wahl zwischen verschiedenen
Verteilung der Eingaben, was zur Folge hat, dass das Ergebnis der Analyse
von dieser Wahl abhängt.
• Ressourcenbedarf für in der Praxis vorkommende Fälle von Eingaben. Dies
muss abhängig vom Problem formuliert werden.
Einen Algorithmus nennt man effizient, wenn er wenig Ressourcen benötigt,
(d.h. wenig Zeit und wenig Speicher . . . ). Diese Definition ist nicht formal,
denn ein Algorithmus soll ja eine Klasse von Problemen lösen. Oftmals sind die
beiden Ziele konkurrierend, d.h. die Optimierung des Zeitbedarfs ist teilweise
mit einer Verschlechterung des Platzbedarfs erkauft. Eine weitere Möglichkeit,
einen effizienten Algorithmus zu konstruieren, ist die Einschränkung der Klasse
von Problemen.
Einen Algorithmus nennt man optimal, wenn er “nicht schlechter“ ist als andere Algorithmen zur gleichen Problemklasse. Die Bedeutung von “nicht schlechter“ kann bzgl. Zeit oder Platz oder anderen Ressourcen sein. Es kommt auch
vor, dass man nur die Größenordnungen vergleicht (siehe unten).
Nimmt man die verbrauchte Zeit als Maßstab, so hängt diese natürlich von
der Verarbeitungsgeschwindigkeit des benutzten Systems ab.
O-Schreibweise
Die O-Schreibweise wird verwendet, um die asymptotische Größenordnung von
numerischen Funktionen abzuschätzen. Damit kann man Problemklassen grob
klassifizieren. Oft lässt sich auch für Problemklassen der Ressourcenbedarf nicht
genau ermitteln, aber man kann Abschätzungen angeben. Die O-Notation ist
gut geeignet zur einfachen Mitteilung über asymptotische Abschätzungen nach
oben.
Seien R, f : IN+ → IR+ zwei Funktionen: Wir schreiben:
R(n) = O(f (n)), wenn es eine Konstante K gibt, so dass
R(n) ≤ K ∗ f (n) für alle genügend großen n.
Man beachte, dass dies keine Gleichung ist, sondern eine Abschätzung einer
Funktion nach oben, und dass diese Abschätzung nicht immer die bestmögliche
sein muss. Insbesondere wird von einer Konstanten abstrahiert. Statt R(n) =
O(f (n)) sagt man auch R(n) ist von der Größenordnung f (n).
Beispiel 2.4.8
p
√
√
√
• Es gilt n = O( (n)), aber auch n = O(n), denn n ≤ n für alle
positiven ganzen Zahlen n.
• Es gilt p(n) = O(2n ) für alle Polynome p(n).
37
Praktische Informatik 1, WS 2004/05, Kapitel 2, vom 10.11.2004
• Wenn f (n) = O(c ∗ g(n)), dann auch f (n) = O(g(n)).
• Wenn c > 1 und e > 0 eine Konstante, dann gilt: f (n) = O(cn+e ) gdw.
f (n) = O(cn )
• Wenn c, d > 1, dann gilt: f (n) = O(logc (n)) gdw. f (n) = O(logd (n))
• Wenn c, d > 1, und c < d, dann gilt: cn = O(dn ) , aber nicht dn = O(cn ).
• Wenn f (n) = O(g(n)) und g(n) = O(h(n), dann gilt auch f (n) =
O(h(n)).
• Wenn eine Funktion f durch eine Konstante nach oben beschränkt ist,
dann gilt f (n) = O(1).
Beispiel 2.4.9 Wenn man fib in der Größe der eingegebenen Zahl n, misst
bzw. in der Größe der Darstellung der Zahl d, dann gilt
redfib (n)
platzfib (n)
redfib lin (n)
platzfib lin (n)
redfib lin (d)
platzfib lin (d)
=
=
=
=
=
=
O(1.62n )
O(n)
O(n)
O(n)
O(10d )
O(10d )
Es gibt einige Komplexitäten von Algorithmen, die häufig vorkommen. Die
Sprechweisen sind:
O(1)
konstant
O(log(n))
logarithmisch
O(n)
linear
O(n ∗ log(n)) fastlinear (oder auch n-log-n)
O(n2 )
quadratisch
O(n3 )
kubisch
O(nk )
polynomiell
O(cn )
exponentiell für eine Konstante c > 1.
Hier eine beispielhafte Tabelle zur intuitiven Veranschaulichung der unterschiedlichen Komplexitäten.
Eingabedaten
10
100
1000
Algorithmus
log2 (n)
0.000003 sec
0.000007 sec
0.00001
n
0.00001 sec
0.0001 sec
0.001 sec
n2
0.0001 sec
0.01 sec
1 sec
n3
0.001 sec
1 sec
15 min
2n
0.001 sec
4 ∗ 1016 Jahre
nahezu unendlich
Beachte, dass viele Optimierungen, die ein Programm effizienter machen und
die in der Praxis relevant sind, sich in der O-Notation nicht bemerkbar machen.
Praktische Informatik 1, WS 2004/05, Kapitel 2, vom 10.11.2004
38
Oft sind dies konstante Faktoren, um die ein Algorithmus schneller wird. Z.B.
bewirkt die Verwendung eines schnelleren Rechners eine Verbesserung um einen
konstanten Faktor bei der tatsächlich benötigten Zeit, aber keine Änderung bei
der O- Klassifizierung.
Die Bestimmung der genauen Komplexität des Ressourcenbedarfs einer Problemklasse ist ein sehr schweres theoretisches Problem, das nur für wenige Problemklassen exakt genug bekannt ist. I.a. sind nur gute obere Abschätzungen
bekannt, von denen man vermutet, dass diese der Realität entsprechen, während
nichttriviale untere Schranken oft nur sehr schlecht ermittelt werden können.
2.4.4
Ω-Schreibweise
Ergänzend zur O-Notation, die Funktionen nach oben abschätzt, wird die ΩSchreibweise verwendet, um die Größenordnung von numerischen Funktionen
nach unten abzuschätzen. Seien R, f : IN+ → IR+ zwei Funktionen: Wir schreiben:
R(n) = Ω(f (n)), wenn es eine Konstante Kgibt, so dass
R(n) ≥ K ∗ f (n) für alle genügend großen n.
Beispiel 2.4.10 Analog zu O gilt:
√
√
• Es gilt n = Ω( n), denn n ≤ n für alle positiven ganzen Zahlen n.
√
• Es gilt n = Ω(1).
• Es gilt 2n = Ω(p(n)) für alle Polynome p.
• Wenn f (n) = Ω(c ∗ g(n)), dann auch f (n) = Ω(g(n)).
• Wenn c > 1 und e > 0 eine Konstante, dann gilt: f (n) = Ω(cn+e ) gdw.
f (n) = Ω(cn )
• Wenn c, d > 1, dann gilt: f (n) = Ω(logc (n)) gdw. f (n) = Ω(logd (n))
• Wenn c, d > 1, und c < d, dann gilt: dn = Ω(cn ) , aber nicht cn = Ω(dn ).
• Wenn f (n) = Ω(g(n)) und g(n) = Ω(h(n), dann gilt auch f (n) = Ω(h(n)).
• Wenn eine Funktion f durch eine Konstante nach unten beschränkt ist,
dann gilt f (n) = Ω(1).
Beispiel 2.4.11 Berechnung von Potenzen bn für positive ganze Zahlen n.
Die Potenzen sind rekursiv definiert durch
1
falls n = 0
n
b :=
b ∗ bn−1 sonst
Direkte Kodierung des Algorithmus ergibt ein rekursives Programm:
39
Praktische Informatik 1, WS 2004/05, Kapitel 2, vom 10.11.2004
potenz b n =
if n == 0
then 1
else b * (potenz b (n - 1))
Platz- und Zeitbedarf sind von der Größenordnung O(n). Der Zeitbedarf lässt
sich verbessern, indem man folgende Idee ausnutzt: statt b8 = b∗b∗b∗b∗b∗b∗b∗b
berechne
• b2 := b ∗ b
• b4 = b2 ∗ b2
• b8 = b4 ∗ b4
Als allgemeine Rechenvorschrift

 1
bn :=
(bn/2 )2

b ∗ bn−1
halten wir fest:
falls n = 0
falls n gerade und ≥ 2
falls n ungerade
potenz_log b n = if n == 0 then 1
else
if even n
then quadrat (potenz_log b (n ‘div‘ 2))
else b * (potenz_log b (n - 1))
Der Platz- und Zeitbedarf in der Zahl n ist O(log(n)), z.B. für n = 1000
benötigt dieser Algorithmus nur 14 Multiplikationen. Beachtet man noch den
extra Aufwand für die Multiplikationen, die bei beliebigen langen Zahlen (Integer) mit der Länge der Zahl steigt, so ist der wahre Aufwand höher: je nachdem wie diese Multiplikation implementiert ist. Bei normaler Implementierung:
O((log(n)3 ),
2.4.5
Der größte gemeinsame Teiler
Idee zur Berechnung von ggT(a, b) (Euklids Algorithmus)
Teile a durch b gibt Rest r,
wenn r = 0, dann ggT(a, b) := b
wenn r 6= 0, dann berechne rekursiv ggT(b, r).
Beispiel 2.4.12 ggT(30, 12) = ggT(12, 6) = 6
ggt a b =
Es gilt:
if b == 0
then a
else ggt b (rem a b)
Praktische Informatik 1, WS 2004/05, Kapitel 2, vom 10.11.2004
40
Satz 2.4.13 (Lamé): Wenn der Euklidische ggt-Algorithmus k Schritte
benötigt, dann ist die kleinere Zahl der Eingabe ≥ f ib(k).
Daraus kann man sofort herleiten, dass dieser Algorithmus Platz- und Zeitbedarf in der Größenordnung O(log(n)) hat: Wenn n die kleinere Zahl ist und
der Algorithmus k Schritte benötigt, dann ist n ≥ f ib(k) ≈ Φk . Also ist
k = O(log(n)). D.h. der Euklidische ggt-Algorithmus benötigt (im schlimmsten
Fall) O(log(n)) Schritte bei Eingabe der Zahl n.
2.4.6
Test auf Primzahl-Eigenschaft
Das Problem ist: Gegeben eine Zahl n. Frage: ist n eine Primzahl?
1. Methode: finde den kleinsten Teiler:
-ad-hoc test auf Prizahleigenschaft
primzahlq n = n == (kleinster_teiler n)
kleinster_teiler n = finde_teiler n 2
finde_teiler n pruef_teiler =
if n < (quadrat pruef_teiler)
then
n
else
if
teiltq pruef_teiler n
then pruef_teiler
else finde_teiler n (pruef_teiler + 1)
teiltq a b =
0 ==
(rem b a)
Der√(worst-case) Zeitbedarf dieser Funktion ist von der Größenordnung
O( n).
2. Methode: Benutze kleinen Fermatschen Satz.
Satz (Fermat): Wenn p eine Primzahl ist und 1 < a < p,
dann ist ap ≡ a(mod p)
Idee: teste für verschiedene a, ob die Zahl n eine Primzahl sein kann.
Wenn es ein a gibt, so dass der kleine Fermatsche Satz nicht gilt, d.h.
an 6≡ a(mod n), dann ist n keine Primzahl. Man kennt dann zwar keinen
Teiler von n, aber weiß, dass n keine Primzahl sein kann. Wenn für eine
ausreichende Zahl von Basen a, der Fermat-Test ja sagt, dann kann man
ziemlich sicher sein, dass es eine Primzahl ist. Seltene Ausnahmen sind
aber möglich. Diese Ausnahmen nennt man Carmichael-Zahlen. Die kleinsten sind: 561,1105,1729,2465,2821,6601 ,8911,10585,15841,29341, . . . . Testet man noch mal auf Teilbarkeit durch die Primzahlen bis 43, dann
schliet man alle Carmichaelzahlen ≤ 106 aus.
Praktische Informatik 1, WS 2004/05, Kapitel 2, vom 10.11.2004
41
potenzmod b e m =
if e == 0 then 1
else if even e
then rem (quadrat (potenzmod b (e ‘div‘ 2) m))
m
else rem (b * (potenzmod b (e - 1) m))
m
-- randomInts ist in Library random.hs
fermat_test_it n rnd =
let a = (rnd ‘mod‘ n)
in (potenzmod a n n) == a
fermat_test n
= and
(map (\rnd -> fermat_test_it n rnd)
(take 100 (randomInts 2 n))))
Dies ist ein probabilistischer Algorithmus, der mit einer sehr großen Wahrscheinlichkeit das richtige Ergebnis liefert. Der mit map beginnende Ausdruck wendet den Fermat-Test auf 100 Zufallszahlen an, die Funktion and
testet, ob nur “ja“ als Antwort resultiert. Die genaue Funktionalität des
Ausdruck map ... wird klar im Abschnitt über Listen. Eigenschaft: Die
Antwort: False ≡ “ist keine Primzahl“ ist immer korrekt, während True
etwas anderes bedeuten kann als “ist eine Primzahl“ .
Zeitverbrauch pro Test: O(log n).
Vor kurzem wurde nachgewiesen, dass die Prüfung einer Zahl auf Primzahleigenschaft in polynomieller Zeit in Abhängigkeit von der Darstellungsgröße der Zahl durchgeführt werden kann (d.h. in O(log(n)) ).
(Siehe M. Agrawal, N. Kayal, N. Saxena: PRIMES is in P. siehe auch
http://www.cse.iitk.ac.in/users/manindra (2002))
Beispiel 2.4.14 Fermatsche Primzahlen
Es gab die Vermutung von Pierre de Fermat, dass alle Zahlen der Form
n
22 + 1, wobei n ≥ 1, immer Primzahlen sind. Diese Zahlen treten im Zusammenhang mit der geometrischen Konstruierbarkeit mittels Zirkel und Lineal von
gleichseitigen Vielecken auf. Diese sind konstruierbar, wenn die Anzahl der Seiten die Form 2m ∗ p hat und p eine Fermatsche Primzahl ist. Für n = 4 ist dies
4
die Zahl 22 + 1 = 65537. Bisher gibt es keine bekannten größeren Primzahlen
dieser Form.
Diese Fermatsche Vermutung kann leicht mittels des Primzahltests widerlegt
werden für n = 5, 6, 7, 8, 9
Main> fermat_prim_test 4
(65537,True)
-- ist Primzahl
Main> fermat_prim_test 5
Praktische Informatik 1, WS 2004/05, Kapitel 2, vom 10.11.2004
42
(4294967297,False)
Main> fermat_prim_test 6
(18446744073709551617,False)
Main> fermat_prim_test 7
(340282366920938463463374607431768211457,False)
Einen Teiler mit den bisher programmierten Funktionen für n ≥ 10 zu berechnen dauert sehr (zu) lange. Mit der iterativen Version von potenzmod ist
auch die Vermutung für n = 10, 11, 12, 13, 14 im Interpreter widerlegbar.
Zitat aus einer Bemerkung: Von einigen Fermatschen Zahlen n = 14,
”
20, 22, 24), weiß man bisher nur, dass sie zusammengesetzt sind, ohne
einen einzigen Faktor zu kennen. Von vielen Fermatschen Zahlen (n =
33, 34, 35, 40, 41, 44, 45, 46, 47, 50, . . .) ist bisher weder bekannt, ob sie Primzahlen sind, noch dass sie zusammengesetzt sind.
”
Beispiel 2.4.15 Mersennesche Primzahlen
Diese Primzahlen sind von der Form 2p − 1, wobei p selbst eine Primzahl
ist. Mersennesche Primzahlen sind auch Rekordhalter als größte bekannte
Primzahlen
Für folgende Werte von p sind diese Zahlen tatsächlich Primzahlen:
2, 3, 5, 7, 13, 17, 19, 31, 61, 89, 107, 127, 521, 607, 1279, 2203, 2281,
3217, 4253, 4423, 9689, 9941, 11213, 19937, . . .
Der aktuelle Rekord ist p = 24036583, d.h. die Mersennesche Primzahl
224036583 − 1, siehe auch http://www.mersenne.org/history.htm#found.
Nach einer (iterativen) Optimierung von potenzmod kann man das auch bis zu
p = 2281 mit dem Haskell Interpreter testen, z.B.
*Main> mersenne_prim_test 127
(170141183460469231731687303715884105727,True)
und erhält, dass die zugehörige Mersennesche Zahl mit sehr großer Wahrscheinlichkeit eine Primzahl ist.
2.5
Funktionen auf Listen
Wir führen Listen und Funktionen auf Listen hier ein, um jetzt schon komplexere
Funktion schreiben zu können, die Besprechung von Datentypen, insbesondere
eine genauere des Datentyps Liste, ist im nächsten Abschnitt.
Listen sind eine Datenstruktur für Folgen von gleichartigen, gleichgetypten
Objekten.
Beispiel 2.5.1 [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] ist die Liste der 10 Ziffern. Der Typ der
Liste ist [Integer]; d.h. Liste von Integer.
[] ist die Liste ohne Elemente (leere Liste, Nil);
[’a’, ’b’] ist eine Liste mit den 2 Zeichen ’a’ und ’b’. Der Typ ist [Char];
43
Praktische Informatik 1, WS 2004/05, Kapitel 2, vom 10.11.2004
d.h. Liste von Char, auch abgekürzt als String.
Die Liste [’a’, ’b’, ’c’] wird auch als "abc" angezeigt.
Man kann auch Listen von Listen verwenden:
[[], [0], [1, 2]] ist eine Liste mit drei Elementen. Der Typ ist [[Integer]], d.h. eine
Liste von Listen von Integer-Objekten.
In Haskell sind auch potentiell unendliche Listen möglich und erlaubt:
Der Haskell-Ausdruck [1..] erzeugt nacheinander alle natürlichen Zahlen.
[1, 2, 3, 4, 5, .... Die komplette Auswertung der Liste, um diese anzuzeigen, terminiert natürlich nicht. Trotzdem ist diese Schreibweise in Fällen brauchbar, in
denen man nur Teile der Liste auswerten will, aber vorher nicht weiss, wieviel.
Es gibt zwei verschiedene Schreibweisen für Listen: [0, 1, 2] wird auch als
(0 : (1 : (2 : []))) geschrieben. Die letzte Darstellung entspricht der internen
Darstellung, Beide sind in Haskell intern vollkommen gleich dargestellt. Die interne Darstellung wird mit den zwei Konstruktoren : und [] aufgebaut. Hierbei
ist : ein zweistelliger Konstruktor, der im Haskellprogramm infix geschrieben
wird, und [] eine Konstruktorkonstante.
Eine Baumdarstellung der Liste (0 : (1 : (2 : []))) sieht so aus:
0
:?
 ???


??

??


1
:=
==
==
==
=
2
:
<<<
<<
<<
<
[]
Eingebaute, listenerzeugende Funktionen sind:
[n..]
erzeugt die Liste der Zahlen ab n.
[n..m] erzeugt die Liste der Zahlen von n bis m
[1..10] ergibt [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
Es gibt vordefinierte Funktionen auf Listen in Haskell. Die sind aber vom
Verhalten her nicht zu unterscheiden von selbst definierten Funktionen.
Oft sind die Definitionen der Listen-Funktionen aufgeteilt in zwei definierende Gleichungen: Es werden die Fälle der Struktur des Listen-Arguments unterschieden: Das Listenargument kann die leere Liste sein oder eine nichtleere
Liste. Wenn es mehr Listenargumente sind, können s auch entsprechend mehr
Fälle sein.
Da length die vordefinierte Funktion zum Berechnen der Länge einer Liste
ist, nehmen wir einen anderen Namen.
-Laenge einer Liste
lengthr []
= 0
lengthr (_:xs) = 1 + lengthr xs
Praktische Informatik 1, WS 2004/05, Kapitel 2, vom 10.11.2004
44
-map wendet eine Funktion f auf alle Elemente einer Liste an.
map f []
= []
map f (x:xs)
= f x : map f xs
Die erste Funktion berechnet die Anzahl der Elemente einer Liste, d.h. deren
Länge, die zweite Funktion wendet eine Funktion auf jedes Listenelement an und
erzeugt die Liste der Resultate.
Die Ausdrücke [] und (x:xs) nennt man Muster. Man kann sie analog zu
formalen Parametern verwenden, wobei die Voraussetzung ist, dass das Argument zum entsprechenden Muster passt, evtl. nach einigen Auswertungsschritten. Zum Beispiel wird map quadrat (1:[]) per Definitionseinsetzung unter
Benutzung der zweiten Gleichung zu f 1 : map f [] reduziert, wobei 1 für x
und [] für xs eingesetzt wurde.
Ein weiteres Beispiel ist:
map (+ 1) [1..10]
addiert zu jedem Listenelement die Zahl 1. Das Ergebnis ist die Liste
[2, 3, 4, 5, 6, 7, 8, 9, 10, 11].
Strings oder Zeichenketten in Haskell sind Listen von Zeichen und können auch
genauso verarbeitet werden.
Die folgende Funktion hängt zwei Listen zusammen:
append [] ys
append (x:xs) ys
= ys
= x : (append xs ys)
In Haskell wird diese Funktion als ++ geschrieben und Infix benutzt.
Beispiel 2.5.2
Main> 10:[7,8,9]
[10,7,8,9]
Main> length [3,4,5]
3
Main> length [1..1000]
1000
Main> let a = [1..] in (length a,a)
ERROR - Garbage collection fails to reclaim sufficient space
Main> map quadrat [3,4,5]
[9,16,25]
Main> [0,1,2] ++ [3,4,5]
[0,1,2,3,4,5]
Weitere wichtige Funktionen auf Listen sind:
Filtern von Elementen aus einer Liste:
Praktische Informatik 1, WS 2004/05, Kapitel 2, vom 10.11.2004
filter f []
filter f (x:xs)
45
= []
= if (f x) then x : filter f xs
else filter f xs
Die ersten n Elemente der Liste xs:
take 0 _
= []
take n []
= []
take n (x:xs) = x : (take (n-1) xs)
Die verzögerte Auswertung wird so gesteuert, dass man bei einem Ausdruck
der Form f s1 . . . sn die Definitionseinsetzung erst macht, wenn die Argumente
bei denen eine Fallunterscheidung notwendig ist, ausgewertet sind. Diese Auswertung erfolgt von links nach rechts. Es wird nur soviel von den Argumenten
ausgewertet wie nötig, um die Fallunterscheidung machen zu können.
Ein Beispiel zur Reihenfolge der Auswertung von Ausdrücken mit take:
repeat x = x : repeat x
Auswertung:
take 10 (repeat 1)
take 10 (1:repeat 1)
1:(take (10-1) (repeat 1))
## (x:xs) = (1:(repeat 1))
1:(take 9 (repeat 1))
1:(take 9 (1:(repeat 1)))
1:(1:(take (9-1) (repeat 1))
...
1:(1: ...
1:(take (1-1) (repeat 1))
1:(1: ...
1:(take 0 (repeat 1)))
## n = 0
1:(1: ...
1:[]
Bei Verwendung von Listenargumenten bleiben die Definitionen der Begriffe
linear rekursiv, end-rekursiv, Baum-rekursiv und verschachtelt Baum-rekursiv
unverändert, jedoch muss der Begriff iterativ angepasst werden.
Wir sprechen von einem iterativen Auswertungsprozess, wenn die
Auswertung eines Ausdrucks (f a1 . . . an ) nach einigen Auswertungen als
(Rückgabe-)Wert einen Ausdruck der Form (f b1 . . . bn ) auswerten muss, und
dies für die ganze Rekursion gilt. Hierbei muss für die Argumente pro Index i
und für die gesamte Rekursion folgendes gelten: Entweder sind ai , bi Basiswerte,
oder ai , bi sind komplett ausgewertete, endliche Listen.
Von einer iterativen Version fiter einer rekursiven Funktion f spricht man,
wenn f und fiter bei Eingabe von Basiswerten bzw. endlichen, komplett ausgewerteten Listen, die gleichen Ergebnisse berechnen, und fiter bei Eingabe von
endlichen, ausgewerteten Listenargumenten einen iterativen Prozess erzeugt.
Bei Listenargumenten spricht man von ausgewerteten Argumenten, wenn
das Argument soweit ausgewertet ist, dass man die Fallunterscheidung machen
kann. Entsprechend muss der Begriff applikativ und strikt angepasst werden:
46
Praktische Informatik 1, WS 2004/05, Kapitel 2, vom 10.11.2004
Applikativ ist eine Funktion, wenn zuerst alle Argumente, auch Listenargumente
(nicht komplett) ausgewertet werden; Strikt, wenn die Funktion auf jeden Fall
alle Argumente auswertet (in diesem Sinne).
Z.B benutzt die folgende Version der length-Funktion die iterative Funktion
length linr:
length_lin xs
= length_linr 0 xs
length_linr s []
= s
length_linr s (x:xs) = strikt_1 s (length_linr (s+1) xs)
Diese Funktion ist nur fast iterativ. Eigentlich muss man folgende Funktionen
definieren:
length_lin xs
length_linr s []
length_linr s (x:xs)
length_linr_app s xs
= length_linr 0 xs
= s
= (length_linr_app (s+1) xs))
= strikt_1 s (length_linr s xs)
oder mit let (siehe unten):
length_lin xs
= length_linr 0 xs
length_linr s []
= s
length_linr s (x:xs) = let s1 = s+1 in (s1 ‘seq‘ (length_linr_app s1 xs))
Damit wird die Funktion
Allgemeine Funktionen auf Listen
Zwei allgemeine Funktionen (Methoden), die Listen verarbeiten sind foldl und
foldr 8 und z.B. die Summe aller Elemente einer Liste“ verallgemeinern.
”
Die Argumente sind:
• eine zweistellige Operation,
• ein Anfangselement (Einheitselement) und
• die Liste.
Hiermit kann z.B. die Summe über eine Liste von Zahlen gebildet werden:
sum xs = foldl (+) 0 xs
Aus Effizienzgründen ist abhängig vom zweistelligen Operator mal foldr und
mal foldl besser geeignet.
produkt xs = foldl (*) 1 xs
concat xs = foldr (++) [] xs
--
oder
(foldl’ (*) 1 xs)
Für weitere Funktionen auf Listen siehe Prelude der Implementierung von
Haskell bzw. Dokumentation in Handbüchern oder in www.haskell.org.
8 Diese
werden noch genauer besprochen
Praktische Informatik 1, WS 2004/05, Kapitel 2, vom 10.11.2004
2.6
47
Lokale Funktionsdefinitionen, anonyme
Funktion, Lambda-Ausdrücke in Haskell
Im folgenden stellen wir weitere Möglichkeiten von Haskell vor. Zu diesen Erweiterungen werden wir auch i.a. die Definition der Auswertung angeben. Die
Sätze von Church-Rosser gelten unter sehr starken Einschränkungen noch, aber
nicht mehr in der allgemeinsten Variante der Sprache (siehe Abschnitt ?? ).
Man kann Funktionen an der Stelle der Benutzung definieren: Die Syntax für
einen Lambda-Ausdruck9 ist:
\x1 . . . xn -> hAusdrucki
In der Syntax von Haskell hat obiger Lambda-Ausdruck den Status eines
Ausdrucks. Damit kann man z.B. die Quadrat-Funktion auf eine weitere Weise
definieren, die aber in der Wirkung völlig äquivalent zur bisherigen Definition
ist.
quadrat = \x
-> x*x
Bemerkung 2.6.1 Der Ausdruck \x1 -> (\x2 -> ... (\xn -> t) ...) ist
bei Berechnung von Werten äquivalent zu \x1 x2 ... xn -> t. Es gibt nur
einen leichten Unterschied bei Anwendung auf weniger als n Argumente, wie
wir sehen werden.
Wir bezeichnen im folgenden die syntaktische Gleichheit von Ausdrücken
mit ≡, d.h. s ≡ t, wenn s und t die gleichen Ausdrücke sind (inklusive aller
Namen).
Man kann lokale Bindungen (und damit auch lokale Funktionen) mit let
definieren.
Die Syntax des let ist wie folgt:
let {x1 = s1 ; . . . ; xn = sn } in t
wobei xi verschiedene Variablennamen sind und si und t Ausdrücke.
Dies ist ein sogenanntes rekursives let, das auch in Haskell benutzt wird. Es
ist z.B. erlaubt, dass x1 in s1 , s2 vorkommt, und dass gleichzeitig x2 in s1 und
s2 vorkommt. D.h. der Gültigkeitsbereich der definierten Variablen xi ist der
ganze let-Ausdruck.
Die Fakultätsfunktion und deren Anwendung lassen sich dann folgendermaßen
programmieren:
let fakt = \x -> if x <= 1 then 1 else x*(fakt (x-1)) in fakt
9 Kommt vom Lambda-Kalkül von Church, der das Zeichen λ (Lambda) als Bindungsoperator für Variablen verwendet. Haskell verwendet das Zeichen \. Siehe auch Extra-Skript
Einige mathematische Grundlagen für Informatiker“.
”
Praktische Informatik 1, WS 2004/05, Kapitel 2, vom 10.11.2004
48
Auswertung der Anwendung von fakt auf 5 ergibt:
(let fakt = \x -> if
-> let fakt = ...
in
-> let fakt = ...
in
-> let fakt = ...
in
-> let fakt = ...
in
....
x <= 1 then 1 else x*(fakt (x-1)) in fakt)
(fakt 5)
if 5 <= 1 then 1 else 5*(fakt (5-1))
if False then 1 else 5*(fakt (5-1))
5*(fakt (5-1))
ergibt: 120.
Bemerkung 2.6.2 Let-Ausdrücke bieten als weitere benutzerfreundliche syntaktische Möglichkeit, Funktionen direkt in einem rekursiven let zu definieren,
ohne syntaktisch einen Lambda-Ausdruck zu schreiben. Die Definition einer
Funktions-Variablen wird direkt als Lambda-Ausdruck umgesetzt. D.h. das ist
sogenannter syntaktischer Zucker, der wegtransformiert werden kann:
let {f x1 . . . xn = s; . . .} in t
ist das gleiche wie:
let {f = \x1 . . . xn -> s; . . .} in t
2.6.1
Freie und Gebundene Variablen
Da die Erklärung lokaler Namen (Variablen ) in Lambda-Abstraktionen und
Let-Ausdrücken geschachtelt auftreten kann, braucht man Regeln, um den Geltungsbereich von lokalen Namen (Variablen) zu ermitteln. Dies führt zu den
Begriffsbildungen der freien und gebundenen Variablen.
Wenn man erst mal durchschaut hat, wo das Problem der gebundenen und
freien Variablen liegt, kann man es sich einfach machen und dafür sorgen, dass alle gebundenen Variablennamen (formalen Parameter, Variablen in einem Lambda gebunden) verschieden sind. Danach kann man sorglos damit umgehen, man
muss nur noch darauf achten, bei Kopien von Unterausdrücken in den verschiedenen Kopien die Namen der gebundenen Variablen auch verschieden zu
benennen.
Da der gleiche Sachverhalt auch in anderen Bereichen auftaucht, wollen wir
es hier genauer analysieren. Zudem zeigt erst die genaue Analyse, wie man richtig umbenennt, d.h. wie man dafür sorgt, dass die Variablennamen verschieden
werden, ohne die operationelle Semantik des Programms zu verändern.
Der Begriff freie und gebundene Variablen soll jetzt eingeführt werden. Im
Ausdruck \x-> x*x ist die Variable x gebunden: sie wird von λ (bzw. \) gebunden. Betrachtet man nur den Ausdruck x*x so ist die Variable x frei in x*x
.
Wir definieren die Begriffe freie Variablen“ bzw. gebundene Variablen“
”
”
zunächst nur für die bereits vorgestellte Syntax.
5
49
Praktische Informatik 1, WS 2004/05, Kapitel 2, vom 10.11.2004
Definition 2.6.3 (Freie Variablen in t) Für den Bindungsbereich der Variablen in einem Lambda-Ausdruck definiert man zunächst den Begriff freie Variablen eines Ausdrucks. Als Variablen kann man nur Bezeichner (Namen) verwenden, die keine Konstruktoren sind (d.h. True, False, Zahlenkonstanten und
Characterkonstanten sind nicht zulässig).
Wir definieren F V (t), die Menge der freien Variablen eines Ausdrucks t. Hier
ist zu beachten, dass t hier als Programmtext bzw. Syntaxbaum gemeint ist. Das
Ergebnis ist eine Menge von Namen.
• F V (x) := {x} , wenn x ein Variablenname ist.
• F V ((s t)) := F V (s) ∪ F V (t).
• F V (if t1 then t2 else t3 ) := F V (t1 ) ∪ F V (t2 ) ∪ F V (t3 ).
• F V (\x1 . . . xn -> t) := F V (t) \ {x1 , . . . , xn }. Dies gilt, da die Variablen
x1 , . . . , xn von \x1 . . . xn gebunden werden.
• F V (let x1 = s1 , . . . , xn = sn in t) := (F V (t) ∪ F V (s)) \ {x1 , . . . , xn }.
• F V (let f x1 . . . xn = s in t) := F V (let f = \x1 . . . xn -> s in t)
• Der allgemeine
 Fall ist:

= s1 , 
 f1 x1,1 . . . x1,n1
......
F V (let
in


f
x
.
.
.
x
=
s
m
m,1
m,n
m
m


->
s1 , 
 f1 = \x1,1 . . . x1,n1
......
F V (let
in t)


fm = \xm,1 . . . xm,n1 ->
sm
t)
:=
Freie Vorkommen von Variablen in t sind die Unterausdrücke x, die nicht
unter einem Lambda bzw- let stehen, das x bindet.
Beispiel 2.6.4 Zur Berechnung der freien Variablen:
F V (\x -> (f x y))
=
=
=
=
F V (f x y) \ {x}
...
{x, f, y} \ {x}
{f, y}
Entsprechend zu freien Variablen werden die gebundenen Variablen eines
Ausdrucks definiert: Es ist die Menge aller Variablen in t, die von einem λ oder
einem let gebunden werden.
Definition 2.6.5 Gebundene Variablen GV (t) von t
• GV (x) := ∅.
• GV ((s t)) := GV (s) ∪ GV (t).
50
Praktische Informatik 1, WS 2004/05, Kapitel 2, vom 10.11.2004
• GV (if t1 then t2 else t3 ) := GV (t1 ) ∪ GV (t2 ) ∪ GV (t3 )
• GV (\x1 . . . xn -> t) := GV (t) ∪ {x1 , . . . , xn }
• GV (let x1 = s1 , . . . , xn = sn in t) := (GV (t) ∪ GV (s) ∪ {x1 , . . . , xn })
• GV (let f x1 . . . xn = s in t) := GV (let f = \x1 . . . xn -> s in t)
• Der allgemeine
 Fall ist:

= s1 , 
 f1 x1,1 . . . x1,n1
......
GV (let
in


f
x
.
.
.
x
=
s
m
m,1
m,n
m
m


->
s1 , 
 f1 = \x1,1 . . . x1,n1
......
GV (let
in t)


fm = \xm,1 . . . xm,n1 ->
sm
t)
:=
Beispiel 2.6.6 Zur Berechnung von gebundenen Variablen:
GV (\ x -> (f x y))
=
=
=
=
GV (f x y) ∪ {x}
...
∅ ∪ {x}
{x}
Die Variablen xi im Rumpf t des Lambda-Ausdrucks \x1 . . . xn -> t sind
gebundene Variablen im Ausdruck \x1 . . . xn -> t.
Die Definition der freien Variablen definiert gleichzeitig den Begriff des
Gültigkeitsbereichs einer Variablen. Haskell verwendetlexikalische Gültigkeitsbereiche (lexical scoping) von Variablen.
• In let x1 = s1 , . . . , xn = sn in t werden genau die Vorkommen der freien
Variablen xi , i = 1, . . . , n, die in den Ausdrücken si , i = 1, . . . , n und t
vorkommen, gebunden. Das ist genau der Gültigkeitsbereich der Variablen
xi , i = 1, . . . , n.
• In \x1 . . . xn -> t werden genau die freien Variablen x1 , . . . , xn in t gebunden. Das ist der Gültigkeitsbereich der Variablen x1 , . . . , xn .
Beispiel 2.6.7 Im Ausdruck \x -> (x (\x -> x*x)) ist x gebunden, aber in
zwei Gültigkeitsbereichen
(Bindungsbereichen.)
Im Unterausdruck s ≡ (x (\x -> x*x)) kommt x frei und gebunden vor. Benennt man die gebundene Variable x in y um, so ergibt sich: (x (\y -> y*y)).
Beispiel 2.6.8 Der folgende Ausdruck hat zwei Gültigkeitsbereichen (Bindungsbereiche) für verschieden erklärte, aber gleich benannte Bezeichner x.
let x = 10 in (let x = 100 in (x+x)) + x
Macht man die Bindungsbereiche explizit und benennt um, dann ergibt sich:
51
Praktische Informatik 1, WS 2004/05, Kapitel 2, vom 10.11.2004
let x1 = 10 in (let x2 = 100 in (x2+x2)) + x1
Dieser Term wertet zu 210 aus.
Die Eingabe eines Ausdrucks wie
let x = (x*x) in (x+x)
führt zu Nichtterminierung. Im Hugs ergibt sich ein Laufzeitfehler.
Beispiel 2.6.9
let
y = 20*z
x = 10+y
z = 15
in x
Oder in anderer Syntax:
let
{y = 20*z;
x = 10+y ; z = 15}
in x
Dies wertet aufgrund der obigen Bindungsregel aus zu : 310.
Beispiel 2.6.10
let {x = 1;y = 2}
in (let {y =3;z = 4}
in (let z = 5
in (x+y+z)))
x = 1;y = 2
y = 3, z = 4
z=5
(x + y + z)
Praktische Informatik 1, WS 2004/05, Kapitel 2, vom 10.11.2004
52
Die zugehörige Bindung zu einer Variablen ist immer die innerste Möglichkeit. Z.B. z ist in der innersten Umgebung an 5 gebunden. Auswertung ergibt:
1+3+5=9
Beispiel 2.6.11 Ein Programm mit den Definitionen fi := ei | i = 1, . . . , n und
dem auszuwertenden Ausdruck main kann als großes“ let betrachtet werden:
”
let {f1 := e1 ; . . . ; fn := en } in main
Beispiel 2.6.12 Dieses Beispiel soll zeigen, dass man mit einem let leicht
redundante Auswertungen vermeiden kann.
Die Funktion f (x, y) := x(1 + xy)2 + y(1 − y) + (1 + xy)(1 − y) läßt sich
effizienter berechnen durch Vermeidung von Doppelberechnungen, wenn man definiert:
a
:= 1 + xy
b
:= 1 − y
f (x, y) := xa2 + yb + ab
Der zugehörige Ausdruck ist:
let a
b
in
2.6.2
= 1 + x*y
= 1 - y
x*a*a + y*b + a*b
Motivation zur Vorgehensweise bei Auswertung mit
letund Lambda
Eine vereinfachte Vorstellung (Sharing-Variante zur Optimierung) von
let-Ausdrücken und deren Wirkungsweise ist folgende: Im Ausdruck
let x = s, ... in t ist der Ausdruck s nur einmal im Speicher, und jedes Vorkommen von x wird so behandelt, als wrde dort ein Verweis auf das
gemeinsam verwendete s stehen.
D.h. diese Sichtweise wrde als äquivalent ansehen:
let x = quadrat 1 in x*x und
(1)
(1)
(quadrat1) ∗(quadrat1) .
Diese Vorstellung ist in vielen Fällen richtig, aber nicht in allen:
1. Das let ist rekursiv, und somit handelt es sich evtl. um zyklische Verweise,
so dass ein iteriertes Kopieren mit Gleichheitsmarkierungen nicht jedes let
eliminieren kann.
2. Die Sharing-Variante lst nicht den Konflikt zwischen der Notwendigkeit, beim Kopieren von Abstraktionen den Rumpf wirklich zu verdoppeln, aber die bisherigen Gleichheitsmarkierung zu beachten. Z.B.
let x = quadrat 2, z = \y. y+x in (z 3)+ (z 5)
Eine Möglichkeit, die operationale Semantik eindeutig und sauber zu definieren, ist die Angabe auf Transformationsregeln auf den Ausdrücken mit let,
zusammen mit einer Auswertungsstrategie.
Praktische Informatik 1, WS 2004/05, Kapitel 2, vom 10.11.2004
53
In diesem Paragraphen werden wir nur die Transformationsregeln angeben,
und deren Wirkungsweise anschaulich machen. Die verzögerte Reduktion soll
dann durch die neue Auswertungsmethode ersetzt werden.
Die Auswertungsstrategie werden wir nur andeuten bzw. durch Prinzipien
verdeutlichen.
Beachte: Die verzögerte Auswertung wie bisher definiert ist eine Auswertungsstrategie nur für let- und lambda-freie Ausdrücke.
2.6.3
Reduktion für Lambda-Ausdrücke
Da let das Sharing mit modellieren soll, muss die Regel für die Definitionseinsetzung angepasst werden:
Definitionseinsetzung-let
P [(f t1 . . . tn )] → (let{x1 = t1 ; . . . ; xn = tn } in Rumpf0f )
Wenn f die Stelligkeit n hat und die formalen Parameter im Rumpf
von f die Namen x1 , . . . xn haben, Rumpff der Rumpf der Funktionsdefinition von f ist und Rumpf0f eine umbenannte Version vonRumpff
ist.
Die Reduktionsregel für Lambda-Ausdrücke ist genau die Entsprechung der
Regel Definitionseinsetzung-let.
Bei Lambda-Ausdrücken sind die formalen Variablen im Lambda-Kopf
explizit erwähnt, während bei Funktionsdefinitionen die formalen Parameter
nur in der Definition sichtbar deklariert sind.
Definition 2.6.13 Reduktionsregel für Anwendungen von Lambdaausdrücken
auf Argumente:
Beta-Reduktion-let
((\ x1 . . . xn -> e) t1 . . . tn )
→
(let{x1 = t1 . . . xn = tn } in e)
Definition 2.6.14 Umbenennung von Variablen in Lambda-Ausdrücken.
\x1 . . . xn -> e
−→
\y1 . . . yn -> e[y1 /x1 , . . . , yn /xn ]
wenn yi Variablennamen sind, die nicht in e vorkommen. Die Notation
e[y1 /x1 , . . . , yn /xn ] bedeutet, dass für alle i in e die freien Vorkommen der Variablen xi durch yi ersetzt werden.
Praktische Informatik 1, WS 2004/05, Kapitel 2, vom 10.11.2004
54
Sinnvollerweise benennt man (rekursiv) in allen Lambda- und letUnterausdrücken eines Ausdrucks diese Variablen um, so dass alle Bindungen
verschiedene Variablennamen benutzen.
Das gleiche für Let-Ausdrücke.
Definition 2.6.15 Umbenennung von Variablen in Let-Ausdrücken.
let x1 = s1 , . . . , xn = sn in t
−→
let y1 = s1 [y1 /x1 , . . . , yn /xn ], . . . , yn = sn [y1 /x1 , . . . , yn /xn ] in t[y1 /x1 , . . . , yn /xn ]
wenn yi Variablennamen sind, die nicht in si , t vorkommen.
Beachte, dass freie Variablen eines Ausdrucks nicht umbenannt werden.
Beispiel 2.6.16 Der Ausdruck \x-> (\y-> x ∗ ((\x-> x + y) 5)) kann umbenannt werden durch Ersetzen des inneren x durch z 10 :
\ x-> (\y-> x ∗ ((\z-> z + y) 5)).
Nicht erlaubt ist die Umbenennung von y in x. Dies würde ergeben:
\ x-> (\x-> x ∗ ((\x-> x + x) 5)).
Eine korrekte Umbenennung des äußeren x ergibt:
\ z-> (\y-> z ∗ ((\x-> x + y) 5)). Dies kann man auch testweise in Haskell
eingeben; man erhält für den falschen Ausdruck auch andere Ergebnisse.
>
14
(\x -> (\y -> x*((\x -> x+y) 5))) 2 2
> (\x -> (\x -> x*((\x -> x+x) 5))) 2 2
20
> (\z -> (\y -> z*((\x -> x+y) 5))) 2 2
14
Bei der normalen Reihenfolge der Auswertung werden anonyme Funktionen
(d.h. Lambdaausdrücke) wie Funktionsnamen behandelt.
2.6.4
Reduktion von let-Ausdrücken
Wir geben in diesem Paragraph Transformationsregeln für Let-Ausdrücke an,
die ausreichend sind als operationale Semantik im Sinne einer Auswertungssemantik, und die auch die Verallgemeinerung der verzögerten Reduktions sind.
Im folgenden bezeichnen wir mit s[x]p einen Ausdruck, der an einer Stelle
p das freie Vorkommen von x hat. Auch hier gehen wir vom Syntaxbaum des
Ausdrucks aus.
10 Hier
sind die Variablennamen als konkrete Namen gemeint, nicht als Meta-Variablen
Praktische Informatik 1, WS 2004/05, Kapitel 2, vom 15.11.2004
55
Definition 2.6.17 Die Reduktionsregel für einfache let-Ausdrücke ist so definiert, dass nur ein Vorkommen einer Variablen ersetzt wird.
Let-Kopier-Regel in vier Varianten:
let {x = s; E nv} in e[x]p → let {x = s; E nv} in e[s0 ]p
let {x = s; y = e[x]p ; E nv} in r → let {x = s; y = e[s0 ]p ; E nv} in r
wenn s ein Basiswert, eine Abstraktion, oder f t1 . . . tn mit n < ar(f ) für
eine globale Funktion f ist.
Hierbei ist s0 jeweils der Ausdruck s nach Umbenennung der gebundenen
Variablen.
Let-Kopier-Regel für Konstruktorausdrücke:
→
let {x = c t1 . . . tn ; E nv} in e[x]p
(let {x = c x1 . . . xn , x1 = t1 , . . . , xn = tn ; E nv} in e[c x1 . . . xn ]p )
let {x = c t1 . . . tn ; y = e[x]p ; E nv} in r
→ (let {x = c x1 . . . xn , x1 = t1 , . . . , xn = tn ; y = e[c x1 . . . xn ]p ; E nv} in r)
wenn x ein Konstruktor ist mit n = ar(c).
Damit kann man auch die Definitionseinsetzung nachvollziehen, indem man
zuerst eine Abstraktion kopiert, und dann eine Beta-Reduktion macht.
Zwei Regeln, die let-Ausdrücke transformieren, und die man als SpeicherBereinigung (garbage collection) verstehen kann, nehmen wir noch hinzu:
Let-Speicher-Bereinigung-1
let {x1 = s1 ; . . . ; xn = sn } in e
→
e
wenn e kein freies Vorkommen der Variablen xi hat.
Let-Speicher-Bereinigung-2
let {x1 = s1 ; . . . ; xn = sn , xn+1 = sn+1 , . . .} in e
→
let {xn+1 = sn+1 , . . .} in e
wenn xi für i = 1, . . . , n weder in e noch in einem sj mit j ≥ n + 1 frei
vorkommt.
Da Ausdrücke der Form ((let . . . in . . .) s) auftreten können, geben wir auch
hierfür Regeln an:
Praktische Informatik 1, WS 2004/05, Kapitel 2, vom 15.11.2004
56
Let-Anwendung
((let {x1 = t1 ; . . . ; xn = tn } in t) s)
→
(let {x1 = t1 ; . . . ; xn = tn } in (t s))
Evtl. nach einer Umbenennung von gebundenen Variablen. Der Grund für
diese Umbenennung ist der gleiche wie oben: s könnte ein freies Vorkommen
einer der Variablen xi enthalten.
Zur Reduktion von geschachtelten lets wird eine Glättung bzw. Zusammenfassung der Umgebung verwendet:
Let-Glätten-1
(let {x1 = t1 ; . . . ; xj = (let {y1 = r1 ; . . . ; ym = rm } in tj ); . . . ; xn = tn } in t)
−→
(let {x1 = t1 ; . . . ; xn = tn ; y1 = r1 ; . . . ; ym = rm } in t)
Eine weitere Regel dazu:
Let-Glätten-2
(let {x1 = t1 ; . . . ; xn = tn } in (let y1 = r1 ; . . . ; ym = rm in t))
−→
(let {x1 = t1 ; . . . ; xn = tn ; y1 = r1 ; . . . ; ym = rm } in t)
Um die normale Reihenfolge der Auswertung auch für allgemeinere Programme zu beschreiben, benötigt man den Begriff der Normalform. Genauer braucht man die sogenannte schwache Kopfnormalform (weak head normal
form, WHNF). Sie ist eine Verallgemeinerung des Begriffs Wert eines Aus”
drucks“.
Definition 2.6.18 Ein Ausdruck ist in schwacher Kopfnormalform (WHNF),
wenn er folgende Gestalt hat:
1. Zahl oder Boolescher Basiswert oder Zeichen, d.h. Basiswert.
2. Lambda-Ausdruck
3. c t1 . . . tn und c ist ein Konstruktor mit ar(c) = n.
4. f t1 . . . tn und f ist ein Funktionsname mit ar(f) > n.
5. (let{x1 = s1 ; . . . ; xn = sn } in t und t ist von der Form 1–4.
Die normale Reihenfolge der Auswertung hat das Ziel, Ausdrücke per Reduktion
in eine WHNF zu überführen. Sie wird dabei so gesteuert, dass bei Auswertung
eines Ausdrucks der Form ((let . . . in . . .) s) zunächst das s in nach obiger Regel in den let-Ausdruck geschoben wird. Außerdem werden die let-Ausdrücke
Praktische Informatik 1, WS 2004/05, Kapitel 2, vom 15.11.2004
57
zunächst geglättet, falls das notwendig ist. Bei Auswertung eines let-Ausdrucks
wird zuerst der Ausdruck nach dem in ausgewertet. Will man für eine Variable deren Bindung und den Wert ermitteln (z.B. weil die normale Reihenfolge
dieses x auswerten will), so ist der an x gebundene Ausdruck im innersten
Bindungsbereich zu finden, der x bindet. Diese Bindungen nennt man auch
Bindungsumgebungen oder Blockstruktur von Ausdrücken (bzw. Programmen).
Hat die Auswertung eine WHNF erreicht, so stoppt die Auswertung mit
Erfolg.
Wir geben die genaue Definition der normalen Reihenfolge der Auswertung
hier nicht an, da sie relativ umfangreich ist und nicht mehr wesentlich zum
weiteren Verständnis beiträgt.
Beispiel 2.6.19 Das Fakultäts-Beispiel zur let-Reduktion etwas genauer:
let fakt = \x -> if x <= 1 then 1 else x*(fakt (x-1)) in fakt
Auswertung der Anwendung auf 5 ergibt:
(let fakt = \x -> if x <= 1 then 1 else x*(fakt (x-1)) in fakt) 5
let fakt = ...
in (fakt 5)
let fakt = ...
in ((\y -> ...) 5)
let fakt = ...
in (let y = 5 in if y <= 1 then 1 else y*(fakt (y-1))
let {fakt = ... ; y = 5} in if y <= 1 then 1 else y*(fakt (y-1))
let {fakt = ... ; y = 5} in if 5 <= 1 then 1 else y*(fakt (y-1))
let {fakt = ... ; y = 5} in if False then 1 else y*(fakt (y-1))
let f{akt = ... ; y = 5} in
y*(fakt (y-1))
let {fakt = ... ; y = 5} in
5*(fakt (y-1))
let {fakt = ... ; y = 5} in
5*((\z->...) (y-1))
let {fakt = ... ; y = 5} in
5*(let z = (y-1) in if z <= 1 then 1 else z*(fakt (z-1)))
let {fakt = ... ; y = 5} in
5*(let z = (5-1) in if z <= 1 then 1 else z*(fakt (z-1)))
let fakt = ...
in
5*(let z = (5-1) in if z <= 1 then 1 else z*(fakt (z-1)))
let fakt = ...
in
5*(let z = 4 in if z <= 1 then 1 else z*(fakt (z-1)))
let fakt = ...
in
5*(let z = 4 in if 4 <= 1 then 1 else z*(fakt (z-1)))
let fakt = ...
in
5*(let z = 4 in if False then 1 else z*(fakt (z-1)))
let fakt = ...
in
5*(let z = 4 in z*(fakt (z-1)))
....
let fakt = ... in 5*(4*(3*(2*1)))
...
-> let fakt = ... in 120
120
->
->
->
->
->
->
->
->
->
->
->
->
->
->
->
->
->
->
2.7
Funktionen als allgemeine Methoden
In diesem Abschnitt zeigen wir, dass es relativ einfach ist, Funktionen zu definieren, die als allgemeine Methoden verwendet werden können, wobei diese
wieder Funktionen als Argumente haben können.
58
Praktische Informatik 1, WS 2004/05, Kapitel 2, vom 15.11.2004
Als Beispiele betrachten wir zwei Algorithmen, die auf (arithmetischen)
Funktionen definiert sind, wie z.B. Nullstellenbestimmung, Integrieren, Differenzieren, . . . , und die wir hier als Anwendungsbeispiele besprechen wollen.
Formuliert man diese Algorithmen, so ist es bequem, wenn man die Funktionen direkt als Argumente an eine definierte Haskell-Funktion zur Nullstellenbestimmung übergeben kann, die die allgemeinere Methode implementiert.
Beispiel 2.7.1 Bestimmung von Nullstellen einer stetigen Funktion mit Intervallhalbierung
Idee: Sei f stetig und f (a) < 0 < f (b),
f
a
(a+b)/2
b
• wenn f ((a + b)/2) > 0, dann suche die Nullstelle im Intervall [a, a + b/2].
• wenn f ((a+b)/2) < 0, dann suche die Nullstelle im Intervall [(a+b)/2, b].
Eingabe der Methode intervall halbierung sind
• Name der Haskell-Funktion, die die arithmetischen Funktion implementiert.
• Intervall-Anfang
• Intervall-Ende
• Genauigkeit der Nullstelle (absolut).
suche_nullstelle f a b genau =
let fa = f a
fb = f b
in
if fa < 0 &&
fb > 0
then suche_nullstelle_steigend f a b genau
else if fa > 0 && fb < 0
then suche_nullstelle_fallend f a b genau
else error ("Werte haben gleiches Vorzeichen" ++
(show a) ++ (show b))
suche_nullstelle_steigend f a b genau = suche_nullstelle_r f a b genau
suche_nullstelle_fallend f a b genau = suche_nullstelle_r f b a genau
suche_nullstelle_r f a b genau =
let m = (mittelwert a b)
in if abs (a - b) < genau
59
Praktische Informatik 1, WS 2004/05, Kapitel 2, vom 25.11.2004
then m
else let fm = f m
in if fm > 0
then suche_nullstelle_r f a m genau
else if fm < 0
then suche_nullstelle_r f m b genau
else m
>
suche_nullstelle sin 2 4 0.00001
3.14159012
Den Aufwand der Intervallhalbierungsmethode kann man bestimmen durch
die maximale Anzahl der Schritte: dlog2 (L/G)e, wobei L die Länge des Intervalls
und G die Genauigkeit ist. D.h der Zeitbedarf ist O(log(L/G)), während der
Platzbedarf Größenordnung O(1) hat. Hierbei muss man annehmen, dass die
Implementierung der arithmetischen Funktion f O(1) Zeit und Platz braucht.
Beispiel 2.7.2 Verwendung der Nullstellensuche
n-te Wurzel aus einer Zahl a:
√
n
a
nte_wurzel n a = suche_nullstelle (\x-> x^n -a) 1 (a^n) 0.0000000
*Main> nte_wurzel 10 1024
2.00000000372529
*Main> nte_wurzel 10 10
1.2589254114675787
*Main> (nte_wurzel 10 10)^10
9.999999974058156
2.7.1
Funktionen als Ergebnis
Es kann sehr nützlich sein, auch Funktionen als Ausgabe einer Funktion zu erlauben, z.B., wenn man die Nullstellenbestimmung auf zusammengesetzte Funktionen anwenden will. Das einfachste Beispiel ist die Komposition von Funktionen:
komp::(a -> b) -> (c -> a) -> c -> b
komp f g x = f (g x)
*Main> suche_nullstelle (sin ‘komp‘ quadrat) 1 4 0.00000001
1.772453852929175
60
Praktische Informatik 1, WS 2004/05, Kapitel 2, vom 25.11.2004
(sin ‘komp‘ quadrat) entspricht sin(x2 ) und quadrat ‘komp‘ sin entspricht (sin(x))2 . Der Typ von komp (a -> b) -> (c -> a) -> c -> b bedarf der Erklärung:
Es gibt drei Argumenttypen: (a -> b), (c -> a) und c, wobei a,b,c Typvariablen sind.
D.h. Argument eins und zwei (f1 , und f2 ) sind vom Funktionstyp, Argument
3 ist irgendein Typ. Diese Typen müssen so sein, dass f2 angewendet auf das
dritte Argument, den Typ a ergibt, und f1 angewendet auf dieses Ergebnis den
Typ b.
Wendet man komp nur auf zwei Argumente an, dann hat das Resultat den
Typ: c -> b.
In Haskell ist Komposition schon vordefiniert sin ‘komp‘ quadrat wird
einfach als sin . quadrat geschrieben.
Beispiel 2.7.3 Ein weiteres Beispiel für allgemeine Methoden, die auch Funktionsargumente haben, ist näherungsweises Differenzieren
x
Df (x) :=
x+∆x
f (x + dx) − f (x)
dx
ableitung f dx =
\x -> ((f(x+dx)) - (f x)) / dx
Dies liefert als Resultat eine neue Funktion, die die Ableitung annähert.
*Main> ((ableitung (\x -> x^3) 0.00001) 5)
75.00014999664018
-- korrekter Wert:
75
Hiermit kann man die Newtonsche Methode zur Bestimmung der Nullstellen
beliebiger (gutartiger) Funktionen implementieren.
Vorgehen: Ersetze den Schätzwert y jeweils durch den verbesserten Schätzwert und nehme als Ableitung der Funktion eine approximative Ableitungsfunktion.
61
Praktische Informatik 1, WS 2004/05, Kapitel 2, vom 25.11.2004
y2
y−
y1
f (y)
.
Df (y)
newton_nst f y =
if (abs (f y)) < 0.0000000001
then y
else newton_nst f (newton_nst_verbessern y f)
newton_nst_verbessern y f =
y -
(f y) / (ableitung f
0.00001 y)
Damit kann man leicht näherungsweise Nullstellen bestimmen:
*Main> newton_nst (\x -> x^2-x) 4
1.0000000000001112
> newton_nst cos 2
1.5707963267948961
Dies berechnet die Nullstelle der Funktion x2 − x mit Startwert 4 und als zweites
eine Nullstelle der Funktion cos, die π/2 approximiert.
2.8
Datenstrukturen und Typen in Haskell
Bisher haben wir nur die eingebauten Basisdatentypen wie Zahlen und Wahrheitswerte benutzt.
Basisdatentypen
Ganze Zahlen (Int) umfassen in Haskell nur einen bestimmten Zahlbereich.
Wie in den meisten Programmiersprachen wird der Zahlbereich durch die
Binärzahl b begrenzt, die in ein Halbwort (2 Byte), Wort (4 Byte), Doppelwort (8 Byte) passen, so dass der Bereich −b, b darstellbar ist: −231 , 231 −1.
Die Darstellung ist somit für die Operatoren nicht ausreichend, da die Ergebnisse außerhalb dieses Bereichs sein könnten. Das bedeutet, man muss
Praktische Informatik 1, WS 2004/05, Kapitel 2, vom 25.11.2004
62
damit rechnen, dass bei Operationen (+, −, ∗, /) das Ergebnis nicht in den
Zahlbereich passt, d.h. dass ein Überlauf stattfindet.
Unbeschränkte ganze Zahlen (Integer) Diese kann man in Haskell verwenden, es gibt diese aber nicht in allen Programmiersprachen. Hier ist
die Division problematisch, da die Ergebnisse natürlich nicht immer ganze
Zahlen sind. Verwendet wird die ganzzahlige Division. Die Division durch
0 ergibt einen Laufzeitfehler.
Rationale Zahlen Werden in einigen Programmiersprachen unterstützt; meist
als Paar von Int, in Haskell auch exakt als Paar von Integer.
Komplexe Zahlen sind in Haskell in einem Module Complex verfügbar, der
einige Funktionen implementiert, u.a. auch exp und die Winkelfunktionen
für komplexe Eingaben. Komplexe Zahlen sind in Python direkt verfügbar.
Gleitkommazahlen (Gleitpunktzahlen) (Float). Das sind Approximationen für reelle Zahlen in der Form Mantisse, Exponent , z.B. 1.234e − 40
Die Bedeutung ist 1.234 ∗ 10−40 . Die interne Darstellung ist getrennt in
Mantisse und Exponent. Die arithmetischen Operationen sind alle definiert, aber man muß immer damit rechnen, dass Fehler durch die Approximation auftreten (Rundungsfehler, Fehler durch Abschneiden), dass ein
Überlauf bzw. Unterlauf eintritt, wenn der Bereich des Exponenten überschritten wurde, oder dass Division durch 0 einen Laufzeitfehler ergibt.
Beachte, dass es genaue IEEE-Standards gibt, wie Rundung, Abschneiden, die Operationen usw. für normale Genauigkeit (4 Byte) oder für
doppelte Genauigkeit (8 Byte) funktionieren, damit die Ergebnisse von
Berechnungen (insbesondere finanzielle) auf allen Rechnern den gleichen
Wert ergeben.
Meist sind diese Operationen schon auf der Prozessorebene implementiert,
so dass man diese Operationen i.a. über die Programmiersprache aufruft.
Zeichen, Character. Sind meist ASCII-Zeichen (1 Byte). Ein in Haskell
verfügbarer Standard, der 4 Bytes pro Zeichen verwendet und der es erlaubt, viel mehr Zeichen zu kodieren, und der für (fast) alle Sprachen
Kodierungen bereithält, ist der Unicode-Standard (www.unicode.org). Es
gibt drei Varianten, die es erlauben, Kompressionen zu verwenden, so dass
häufige Zeichen in einem oder 2 Byte kodiert werden. In Unicode gibt es
u.a. zusammengesetzte Zeichen, z.B. wird ü bevorzugt mittels zwei Bytes
kodiert.
Funktionen dazu in Haskell (im Modul Char): ord liefert die HexCodierung eines Zeichens und chr ist die Umkehrfunktion von ord.
Wir werden nun größere, zusammengesetzte Datenobjekte als Abstraktion für
Daten verwenden.
Praktische Informatik 1, WS 2004/05, Kapitel 2, vom 25.11.2004
2.8.1
63
Einfache Typen
Beispiel 2.8.1 Rationale Zahlen Eine rationale Zahl kann man als zusammengesetztes Objekt verstehen, das aus zwei Zahlen, dem Zähler und dem Nenx
ner besteht. Normalerweise schreibt man
für die rationale Zahl mit Zähler x
y
und dem Nenner y.
Die einfachste Methode, dies in Haskell darzustellen, ist als Paar von Zahlen
(x, y). Beachte, dass in Haskell rationale Zahlen bereits vordefiniert sind, und
die entsprechenden Paare (x, y) als x%y dargestellt werden. Z.B.:
Prelude> (3%4)*(4%5)
3 % 5
Prelude> 1%2+2%3
7 % 6
Datenkonversionen macht man mit toRational bzw. truncate. Es gibt rationale Zahlen mit kurzen und beliebig langen ganzen Zahlen.
Paare von Objekten kann man verallgemeinern zu n-Tupel von Objekten:
(t1 , . . . , tn ) stellt ein n-Tupel der Objekte t1 , . . . , tn dar.
Die Typen von Tupeln werde ebenfalls als Tupel geschrieben.
Beispiel 2.8.2
Wir geben Tupel und Typen dazu an. Da der Typ von Zahlen
etwas allgemeiner ist in Haskell, ist auch der Typ der Tupel mit Zahleinträgen in
(1,2,3,True)
Typ (Int, Int, Int, Bool)
(1,(2,True),3)
Typ (Int, (Int, Bool), Int )
Haskell allgemeiner.
("hallo",False)
Typ (String, Bool)
(fakt 100,\x-> x) Typ (Integer, a -> a)
Um mit komplexeren Datenobjekten in einer Programmiersprache umgehen
zu können, benötigt man:
Datenkonstruktoren: Hiermit wird ein Datenobjekt neu konstruiert, wobei
die Teile als Parameter übergeben werden.
Datenselektoren: Funktionen, die gegeben ein Datenobjekt, bestimmte Teile
daraus extrahieren.
Zum Beispiel konstruiert man ein Paar (s, t) aus den beiden Ausdrücken s
und t. Da man die Ausdrücke wieder aus dem Paar extrahieren können muß,
benötigt man die Selektoren fst und snd, für die gelten muß: fst(s, t) = s und
snd(s, t) = t.
In der Syntax betrachten wir nur Ausdrücke (c t1 . . . tn ), bei denen ar (c) = n
gilt.
Für ein n-Tupel benötigt man n Datenselektoren, auch wegen der Typisierbarkeit, für jede Stelle des Tupels einen. Die Definition dieser Selektoren wird
in Haskell syntaktisch vereinfacht durch sogenannte Muster (Pattern). Einfache
Beispiele einer solchen Definition sind:
64
Praktische Informatik 1, WS 2004/05, Kapitel 2, vom 25.11.2004
fst (x,y) = x
snd (x,y) = y
selektiere_3_von_5 (x1,x2,x3,x4,x5) = x3
Diese Muster sind syntaktisch überall dort erlaubt, wo formale Parameter
(Variablen) neu eingeführt werden, d.h. in Funktionsdefinitionen, in LambdaAusdrücken und in let-Ausdrücken.
Das Verwenden von Mustern hat den Vorteil, dass man keine extra Namen
für die Selektoren braucht. Will man die Implementierung verstecken, dann sind
allerdings explizite Selektoren notwendig.
Nun können wir auch den Typ von Tupeln hinschreiben: n-Tupel haben
einen impliziten Konstruktor: (., . . . , .). Der Typ wird entsprechend notiert: Der
| {z }
n
Typ von (1, 1) ist z.B . (Integer, Integer), der Typ von selektiere_3_von_5
ist (α1 , . . . , α5 ) → α3 , und der Typ von (1, 2.0, selektiere_3_von_5) ist
(Integer, Float, (α1 , . . . , α5 ) → α3 ).
Bemerkung 2.8.3 In Haskell kann man Typen und Konstruktoren mittels der
data-Anweisung definieren. Zum Beispiel
data Punkt
= Punktkonstruktor Double Double
deriving(Eq,Show)
data Strecke
= Streckenkonstruktor Punkt Punkt
deriving(Eq,Show)
data Viertupel a b c d = Viertupelkons a b c d
deriving(Eq,Show)
Definition 2.8.4 Ein Muster ist ein Ausdruck, der nach folgender Syntax erzeugt ist:
hMusteri ::= hVariablei | (hMusteri)
| hKonstruktor(n) i hMusteri . . . hMusteri
{z
}
|
n
| (hMusteri, . . . , hMusteri)
Als Kontextbedingung hat man, dass in einem Muster keine Variable doppelt
vorkommen darf. Beachte, dass Zahlen und Character als Konstruktoren zählen.
Die erlaubten Transformationen bei Verwendung von Mustern kann man in
etwa so beschreiben: wenn das Datenobjekt wie das Muster aussieht und die
Konstruktoren übereinstimmen, dann werden die Variablen an die entsprechenden Ausdrücke (Werte) gebunden. Diese Musteranpassung kann man mit der
Funktion
anpassen Muster Ausdruck
11 Das
11
ist keine Haskell-Funktion, sondern eine rekursive Beschreibung
Praktische Informatik 1, WS 2004/05, Kapitel 2, vom 25.11.2004
65
rekursiv beschreiben, wobei das Ergebnis eine Menge von Bindungen, d.h. von
Paaren x → t, wobei x eine Variable und t ein Ausdruck ist.
• anpassen Kon Kon = ∅
• anpassen x t = {x → t}:
(passt; aber keine Bindung notwendig.)
(x wird an t gebunden.)
• anpassen (Kon s1 . . . sn ) (Kon t1 . . . tn ) =
anpassen s1 t1 ∪ . . . ∪ anpassen sn tn
• anpassen (Kon s1 . . . sn ) (Kon0 t1 . . . tn ) = Fail, wenn Kon 6= Kon0 .
Dies bedeutet auch, dass die Musteranpassung erzwingt, dass die Datenobjekte, die an das Muster angepasst werden sollen, zunächst ausgewertet werden
müssen. Muster wirken wie ein let-Ausdruck mit Selektoren kombiniert.
Man kann Zwischenstrukturen in Mustern ebenfalls mit Variablen benennen:
Das Muster (x, y@(z1 , z2 )) wird bei der Musteranpassung auf (1, (2, 3)) folgende
Bindungen liefern: x = 1, y = (2, 3), z1 = 2, z2 = 3.
Datentypen
Mittels der Anweisung data kann man eigene Datentypen definieren. Ein Datentyp korrespondiert zu einer Klasse von Datenobjekten. Hierbei wird für den
Datentyp ein Name eingeführt, und die neuen Namen der Konstruktoren zu diesem Datentyp werden angegeben und genauer spezifiziert: Stelligkeit und Typ
der Argumente. Hierbei darf der Typ auch rekursiv verwendet werden.
Eine sehr flexible Erweiterung ist die Parametrisierung der Datentypen. Z.B. kann man Listen von Paaren von Integer definieren; der Typ ist
dann [(Integer, Integer)]. Das Paar (Integer, Integer) ist dann der TypParameter.
Die Datenkonstruktoren haben den Status eines Ausdrucks in Haskell. Es
gibt eine eigene Anweisung, die Datentypen definiert, wobei man auf die definierten Typnamen zurückgreift und auch rekursive Definitionen machen darf.
Es genügt, nur die Datenkonstrukoren zu definieren, da die Selektoren durch
Musteranpassung (Musterinstanziierung) definierbar sind. In der Typanweisung
werden auch evtl. neue Typnamen definiert. Diese Typnamen können mit einem
anderen Typ parametrisiert sein (z.B. [a]: Liste mit dem dem a).
Jedes Datenobjekt in Haskell muß einen Typ haben. Die eingebauten arithmetischen Datenobjekte haben z.B. die Typen Int, Integer, Float, Char,
.... Datenkonstruktoren sind in der entsprechenden Typdefinition mit einem
Typ versehen worden. Das Typechecking kann sehr gut mit parametrisierten Typen umgehen, erzwingt aber gewisse Regelmäßigkeiten, wie z.B. die Kohärenz
von Listen.
Beispiel 2.8.5 Punkte und Strecken und ein Polygonzug werden in der Zahlenebene dargestellt durch Koordinaten:
66
Praktische Informatik 1, WS 2004/05, Kapitel 2, vom 25.11.2004
data
data
data
data
Punkt(a)
Strecke(a)
Vektor(a)
Polygon a
=
=
=
=
Punkt a a
Strecke (Punkt a) (Punkt a)
Vektor a a
Polygon [Punkt a]
Zur Erläuterung:
data
Schlüsselwort
Punkt(a)
Neuer Datentypname
=
Punkt a a
Neuer Datenkonstruktorname,
zweistellig
Die Argumente des Datenkonstruktors haben gleichen Typ a, der mit dem
Parameter des Typs übereinstimmen muss. Strecke ist ein neuer Datentyp,
der aus zwei Punkten besteht. Es ist unproblematisch, Datentyp und Konstruktor gleich zu benennen, da keine Verwechslungsgefahr besteht. Der Parameter a
kann beliebig belegt werden: z.B. mit Float, Int, aber auch mit [[(Int, Char)]].
Haskell sorgt mit der Typüberprüfung dafür, dass z.B. Funktionen, die für
Punkte definiert sind, nicht auf rationale Zahlen angewendet werden, die ebenfalls aus zwei Zahlen bestehen.
Einige Funktionen, die man jetzt definieren kann, sind:
addiereVektoren::Num a => Vektor a -> Vektor a -> Vektor a
addiereVektoren (Vektor a1 a2) (Vektor b1 b2) =
Vektor (a1 + b1) (a2 + b2)
streckenLaenge (Strecke (Punkt a1 a2) (Punkt b1 b2)) =
sqrt (fromInteger ((quadrat (a1 - b1))
+ (quadrat (a2-b2))))
verschiebeStrecke s v =
let (Strecke (Punkt a1 a2) (Punkt b1 b2)) = s
(Vektor v1 v2) = v
in (Strecke (Punkt (a1+v1) (a2+v2)) (Punkt (b1+v1) (b2+v2)))
teststrecke = (Strecke (Punkt 0 0) (Punkt 3 4))
test_streckenlaenge = streckenLaenge
(verschiebeStrecke teststrecke
(Vektor 10 (-10)))
-------------------------------------------------------streckenLaenge teststrecke
<CR>
> 5.0
test_streckenlaenge
<CR>
Praktische Informatik 1, WS 2004/05, Kapitel 2, vom 25.11.2004
67
> 5.0
Wenn wir die Typen der Funktionen überprüfen, erhalten wir, wie erwartet:
addiereVektoren :: Num a => Vektor a -> Vektor a -> Vektor a
streckenlaenge :: Num a => Strecke a -> Float
test_streckenlaenge :: Float
verschiebeStrecke :: Num a => Strecke a -> Vektor a -> Strecke a
2.8.2
Summentypen und Fallunterscheidung
Bisher können wir noch keine Entsprechung des Booleschen Datentyps selbst definieren. Wir können Klassen von Datenobjekten verschiedener Struktur in einer
Klasse vereinigen, indem wir einem Typ mehr als einen Konstruktor zuordnen:
Dies ist gleichzeitig die Deklaration (Erklärung) eines syntaktischen Datentyps.
Als Beispiel und zur Demonstration definieren wir einen eigenen Booleschen
Datentyp und zugehörige Funktionen:
data Wahrheitswerte = Wahr | Falsch
Die zugehörigen Typen (mit mehr als einem Konstruktor) nennt man auch
Summentypen.
Da Funktionen für Objekte unterschiedlicher Struktur eine Fallunterscheidung
machen müssen, gibt es eine einfache Möglichkeit dies in Haskell hinzuschreiben:
Man schreibt die Funktionsdefinition für jeden Fall der verschiedenen Muster
der Argumente hin.
Wir geben Beispiele für die logischen Funktionen auf unserem Beispieldatentyp.
und1
und1
und1
und1
Wahr Falsch
Wahr Wahr
Falsch Falsch
Falsch Wahr
=
=
=
=
Falsch
Wahr
Falsch
Falsch
oder
und2
und2
Wahr x
Falsch x
= x
= Falsch
Wahr x
Falsch _
= x
= Falsch
oder
und3
und3
Der Unterstrich ist eine anonyme Mustervariable (wildcard). Beachte, dass und2
und und3 gleiches Verhalten haben, während und1 anderes Verhalten hat bzgl.
Terminierung.
Zur Erinnerung: der eingebaute Boolesche Datentyp ist Bool, die Datenkonstruktoren sind True, False und die Funktionen sind : &&, || und not.
Da Summentypen eine Fallunterscheidung erfordern, braucht man ein extra
Primitiv in der Sprache, um diese Fallunterscheidung zu programmieren:
68
Praktische Informatik 1, WS 2004/05, Kapitel 2, vom 25.11.2004
Definition 2.8.6 Die Syntax des case-Ausdruck, der zur Fallunterscheidung
verwendet werden kann:
case hAusdrucki of{h Musteri -> hAusdrucki; . . . ;hMusteri -> hAusdrucki}
Die Kontextbedingung ist, dass die Muster vom Typ her passen. Die Bindungsbereiche der Variablen in den Mustern sind genau die zugehörigen Ausdrücke hinter dem Pfeil (-> ).
Der Gültigkeitsbereich der Variablen in bezug auf das case-Konstrukt kann
an der Definition der freien Variablen abgelesen werden:
F V (case s of
(c1 x11 . . . x1n1 ) → t1 ); . . . ;
(ck xk1 . . . xknk → tk ))
:=
F V (s) ∪ F V (t1 ) \ {x11 , . . . x1n1 } . . .
∪F V (tk ) \ {xk1 , . . . xknk }
GV (case s of
(c1 x11 . . . x1n1 → t1); . . . ;
(ck xk1 . . . xknk → tk ))
:=
GV (s) ∪ GV (t1 ) ∪ {x11 , . . . x1n1 } ∪ . . .
∪F V (tk ) ∪ {xk1 , . . . xknk }
Beispiel 2.8.7 Folgende Definition ist äquivalent zum in Haskell definierten
logischen “und“ (und auch zu und2 und und3) : &&.
und4
x y
= case x of True -> y; False -> False
Beispiel 2.8.8 Folgende Definition ist äquivalent zum normalen if . then .
else. D.h. case-Ausdrücke sind eine Verallgemeinerung des if-then-else.
mein_if
2.8.3
x y z
= case x of True -> y; False -> z
Reduktionen zur case-Behandlung
Es gibt eine Reduktionsregel, die noch benötigt wird. Auch diese schreiben wir
so hin, dass Sharing beachtet wird.
Case-Reduktion
(case (c t1 . . . tn ) of . . . (c x1 . . . xn → s) . . .)
(let {x1 = t1 ; . . . ; xn = tn } in s)
Praktische Informatik 1, WS 2004/05, Kapitel 2, vom 25.11.2004
69
Fallunterscheidungen in Funktionsdefinitionen können als case-Ausdrücke
geschrieben bzw. compiliert werden, allerdings muss man vorher analysieren,
über welches Argument eine Fallunterscheidung notwendig ist. In Haskell wird
bei überlappenden Mustern in der Funktionsdefinition die Strategie Muster
”
von oben nach unten“ verfolgt, die ebenfalls in geschachtelte case-Ausdrücke
übersetzt werden kann.
Folgendes Bild veranschaulicht die Kombination von Transformation und
Auswertung:
Haskell- Programm
Entzuckerung
Programm in Kernsprache
Syntaxanalyse
Syntaxbaum des Programms
Auswertung
(operationelle Semantik)
transformierter Syntaxbaum
Der erste Schritt der Auswertung ist die Transformation einiger Konstrukte
in einfachere (Entzuckerung), so dass man sich bei der Definition der operationellen Semantik-Regeln auf eine Untermenge der syntaktischen Möglichkeiten
von Haskell beschränken kann. Hierbei werden u.a. die Pattern in Haskell und
die Fallunterscheidung bei der Funktionsdefinition in case-Ausdrücke übersetzt.
Beispiel 2.8.9 Wir zeigen, wie z.B. die Funktionsdefinition in einfacheres Haskell transformiert wird.
map f []
map f (x:xs)
= []
= f x : map f xs
Das kann man transformieren zu:
map f lst
= (case lst of [] -> []; (x:xs) -> f x : map f xs)
Man beachte, dass diese Transformation vorher eine Analyse der Art der Fallunterscheidung machen muss. Hier ist zu erkennen, dass Fallunterscheidung über
das zweite Argument gemacht werden muss.
Praktische Informatik 1, WS 2004/05, Kapitel 2, vom 25.11.2004
70
Danach setzen wir die operationale Semantik auf einem Haskell-Fragment
auf, das Funktionsdefinitionen, Konstruktoren, Lambda-Ausdrücke und let
kennt.
Wenn Konstruktoren und case erlaubt sind als Konstrukte, dann:
Definition 2.8.10 Eine schwache Kopf-Normalform (weak head normal form)
(WHNF) ist entweder
1. eine Zahl, oder
2. eine Abstraktion, oder
3. eine Applikation (c t1 . . . tn ), wobei c ein Konstruktor ist mit der Stelligkeit
n, oder
4. eine Applikation (f t1 . . . tn ), f ist eine global definierte Funktion, deren
Stelligkeit > n ist.
5. ein let-Ausdruck let . . . in t, wobei t von einer der Formen 1– 4 ist.
D.h. neben den Zahlen und Funktionen sind jetzt auch Datenobjekte (Daten)
als verallgemeinerte Werte zugelassen. Es ist zu beachten, dass auch Datenobjekte noch beliebige (unausgewertete) Unterausdrücke haben können.
Die Auswertung in normaler Reihenfolge ist jetzt eine Erweiterung der normalen Reihenfolge der Auswertung in Haskell mit rekursivem let, wobei als
zusätzlicher Fall nur der case-Ausdruck mit der case-Reduktion auftritt. Falls
ein case t ... auszuwerten ist, und t kein Wert ist, wendet man rekursiv die
normale Reihenfolge auf t an. Wenn t eine Variable ist, kann man irgendwann
eine der Kopierregeln verwenden. Man kann die Fälle ignorieren, in denen man
nicht weiterkommt, da t zu einem nicht brauchbaren Ausdruck reduziert (z.B.
eine Abstraktion), da Haskells Typcheck dafür sorgt, dass dies nicht vorkommt.
Zu beachten ist, dass die normale Reihenfolge die Argumente eines Konstruktors nicht reduziert. Dies bewirkt, dass unendliche Listen in Haskell verarbeitet
werden können.
Bemerkung 2.8.11 Die Church-Rosser-Sätze gelten in einem abgespeckten
Haskell noch, wenn man die rekursive Verwendung des let und Konstruktoren verbietet.
Allgemein gilt, dass die Church-Rosser-Sätze in Haskell nicht gelten. Ein
Grund ist die rekursive Verwendung des let.
Es gelten analoge Sätze in verallgemeinerter und verbesserter Form. Hierzu
benötigt man das Konzept der Verhaltensgleichheit von Ausdrücken:
Man kann eine Verhaltensgleichheit ∼ definieren, so dass gilt:
• verschiedene Basiswerte a, b sind nicht verhaltensgleich:
a 6= b ⇒ a 6∼ b
• man kann Gleiches durch Gleiches ersetzen
a ∼ b ⇒ P [s] ∼ P [t]
• Reduktion erhält Verhaltensgleichheit:
s→t⇒s∼t
Praktische Informatik 1, WS 2004/05, Kapitel 2, vom 25.11.2004
71
Die Folgerungen sind weitreichend:
Zum Beispiel ist es korrekt, Auswertungen bereits im Compiler zu machen.
In anderen Programmiersprachen gelten entsprechende Aussagen nur sehr
eingeschränkt.
2.9
Rekursive Datenobjekte: z.B. Listen
Listen sind eine Datenstruktur für Folgen von gleichartigen (gleichgetypten)
Objekten. Da wir beliebig lange Folgen verarbeiten und definieren wollen, nutzen
wir die Möglichkeit, rekursive Datentypen zu definieren. Der Typ der Objekte
in der Liste ist nicht festgelegt, sondern wird hier als (Typ-) Variable in der
Definition verwendet. D.h. aber, dass trotzdem in einer bestimmten Liste nur
Elemente eines Typs sein dürfen.
-- eine eigene Definition
data Liste a = Leereliste | ListenKons a (Liste a)
Dies ergibt Datenobjekte, die entweder leer sind: Leereliste, oder deren
Folgenelemente alle den gleichen Typ a haben, und die aufgebaut sind als
ListenKons b1 (ListenKons b2 . . . Leereliste)).
Listen sind in Haskell eingebaut und werden syntaktisch bevorzugt behandelt. Aber: man könnte sie völlig funktionsgleich auch selbst definieren. Im folgenden werden wir die Haskell-Notation verwenden. Die Definition würde man
so hinschreiben: (allerdings entspricht diese nicht der Syntax)
data [a] = []
|
a : [a]
Wir wiederholen einige rekursive Funktionen auf Listen:
length []
length (_:xs)
= 0
= 1 + length xs
map f []
map f (x:xs)
filter f []
filter f (x:xs)
=
=
=
=
append [] ys
append (x:xs) ys
[]
f x : map f xs
[]
if (f x) then x : filter f xs
else filter f xs
= ys
= x : (append xs ys)
Beispiel: einfache geometrische Algorithmen
Wir geben als Beispiel einige geometrische Algorithmen, die man mit den bisherigen Mitteln definieren kann:
Zunchst geben wir die Berechnung der Fläche eines von einem Polygonzug
umschlossenen Areals an, falls dieses konvex ist. Der Test auf Konvexität ist
Praktische Informatik 1, WS 2004/05, Kapitel 2, vom 25.11.2004
72
ebenfalls angegeben. Typisch für geometrische Algorithmen sind Sonderfälle:
Die Sonderfälle, die die Implementierung beachtet, sind:
1. Der Polygonzug muss echt konvex sein, d.h. auch, es darf keine NullStrecke dabei sein, und
2. es dürfen keine drei benachbarten Punkte auf einer Geraden liegen.
Als Beispiel ist die Fläche eines regelmäßigen n-Ecks und ein Vergleich mit
der Fläche des Kreises programmiert.
--
polgonflaeche
data Polygon a = Polygon [Punkt a] deriving Show
polyflaeche poly =
if ist_konvex_polygon poly then
polyflaeche_r poly
else error "Polygon ist nicht konvex"
polyflaeche_r (Polygon (v1:v2:v3:rest)) =
dreiecksflaeche v1 v2 v3 + polyflaeche_r (Polygon (v1:v3:rest))
polyflaeche_r _ = fromInt 0
dreiecksflaeche v1 v2 v3 =
let a = abstand v1 v2
b = abstand v2 v3
c = abstand v3 v1
s = 0.5*(a+b+c)
in sqrt (s*(s-a)*(s-b)*(s-c))
abstand (Punkt a1 a2 ) (Punkt b1 b2 ) =
let d1 = a1-b1
d2 = a2-b2
in sqrt (d1^2 + d2^2)
-- testet konvexitaet: aber nur gegen den Uhrzeigersinn.
ist_konvex_polygon (Polygon []) = True
ist_konvex_polygon (Polygon (p:polygon)) =
ist_konvex_polygonr (polygon ++ [p])
testkonvex =
ist_konvex_polygon (Polygon [Punkt (fromInt 2) (fromInt 2),
Punkt (fromInt (-2)) (fromInt 2),
Punkt (fromInt (1)) (fromInt 1),
Punkt (fromInt 2) (fromInt (-2)) ])
73
Praktische Informatik 1, WS 2004/05, Kapitel 2, vom 25.11.2004
ist_konvex_polygonr (p1:rest@(p2:p3:rest2)) =
ist_konvex_drehung_positiv p1 p2 p3 && ist_konvex_polygonr
rest
ist_konvex_polygonr _ = True
ist_konvex_drehung_positiv (Punkt a1 a2) (Punkt b1 b2) (Punkt c1 c2) =
let ab1 = a1-b1
ab2 = a2-b2
bc1 = b1-c1
bc2 = b2-c2
in ab1*bc2-ab2*bc1 > 0
testpoly = polyflaeche (Polygon [Punkt (fromInt 1) (fromInt 1),
Punkt (fromInt (-1)) (fromInt 1),
Punkt (fromInt (-1)) (fromInt (-1)),
Punkt (fromInt 1) (fromInt (-1)) ])
vieleck n = Polygon [Punkt (cos (2.0*pi*i/n)) (sin (2.0*pi*i/n))
| i <- [1.0..n]]
vieleckflaeche n = polyflaeche (vieleck n)
vieleck_zu_kreis n = let kreis
= pi
vieleck = vieleckflaeche n
ratio
= vieleck / kreis
in (n,kreis, vieleck,ratio)
-- Elimination von gleichen Punkten
--- und kollinearen Tripeln
poly_normeq_r [] = []
poly_normeq_r [x] = [x]
poly_normeq_r (x:rest@(y:_)) =
if x == y
then poly_normeq_r rest
else x: poly_normeq_r rest
poly_norm_koll (x:rest1@(y:z:tail)) =
if poly_drei_koll x y z
then poly_norm_koll (x:z:tail)
else x:poly_norm_koll rest1
poly_norm_koll rest = rest
--testet x,y,z auf kollinearitaet:
poly_drei_koll (Punkt x1 x2) (Punkt y1 y2) (Punkt z1 z2)
(z1-y1)*(y2-x2) == (y1-x1)*(z2-y2)
--
(z1-y1)/(z2-y2) == (y1-x1)/(y2-x2)
=
74
Praktische Informatik 1, WS 2004/05, Kapitel 2, vom 25.11.2004
Allgemeine Funktionen auf Listen
Zwei allgemeine Funktionen (Methoden), die Listen verarbeiten sind foldl und
foldr und z.B. die Summe aller Elemente einer Liste“ verallgemeinern.
”
Die Argumente sind:
• eine zweistellige Operation,
• ein Anfangselement (Einheitselement) und
• die Liste.
foldl
:: (a -> b -> a) -> a -> [b] -> a
foldl f e []
= e
foldl f e (x:xs) = foldl f (f e x) xs
foldr
:: (a -> b -> b) -> b -> [a] -> b
foldr f e []
= e
foldr f e (x:xs) = f x (foldr f e xs)
Für einen Operator ⊗ wie ∗, +, append und ein Anfangselement (Einheitselement) e, wie zB. 1, 0, [] ist der Ausdruck
foldl ⊗ e [a1 , . . . , an ]
äquivalent zu
((. . . ((e ⊗ a1 ) ⊗ a2 ) . . . ) ⊗ an ).
Analog entspricht
foldr ⊗ e [a1 , . . . , an ]
der umgekehrten Klammerung:
a1 ⊗ (a2 ⊗ (. . . (an ⊗ e))).
Für einen assoziativen Operatoren ⊗ und wenn e Rechts- und Linkseins zu ⊗
ist, ergibt sich derselbe Wert. Die Operatoren foldl und foldr unterscheiden
sich bzgl. des Ressourcenbedarfs in Abhängigkeit vom Operator und dem Typ
des Arguments.
Beispiele für die Verwendung, wobei die jeweils bessere Variante definiert
wurde.
sum xs
= foldl (+) 0 xs
produkt xs = foldl (*) 1 xs
concat xs = foldr (++) [] xs
--
Eine Funktionen zum Umdrehen einer Liste:
reverse xs = foldl (\x y -> y:x) [] xs
bzw. (foldl’ (*) 1 xs)
Praktische Informatik 1, WS 2004/05, Kapitel 2, vom 25.11.2004
75
Eine Liste von Listen wird um eine Ebene reduziert, d.h. flachgemacht zu einer
einzigen Liste:
concat xs = foldr append [] xs
Folgende Funktion liefert eine Liste von (Pseudo-) Zufallszahlen.12
randomInts a b
Weitere Listen-Funktionen sind:
-- Restliste nach n-tem Element
drop 0 xs
= xs
drop _ []
= []
drop n (_:xs) | n>0 = drop (n-1) xs
drop _ _
= error "Prelude.drop: negative argument"
Bildet Liste der Paare
zip
:: [a] -> [b] -> [(a,b)]
zip (a:as) (b:bs)
= (a,b) : zip as bs
zip _ _
= []
Bildet aus Liste von Paaren ein Paar von Listen
unzip
unzip
:: [(a,b)] -> ([a],[b])
= foldr (\(a,b) (as,bs) -> (a:as, b:bs)) ([], [])
Beispielauswertungen sind:
drop 10 [1..100]
---->
zip "abcde" [1..] ---->
[11,12,...
[(’a’,1),(’b’,2),(’c’,3),(’d’,4),(’e’,5)]
Prelude> zip [’a’..’z’] [1..] --->
[(’a’,1),(’b’,2),(’c’,3),(’d’,4),(’e’,5),(’f’,6),(’g’,7),(’h’,8),
(’i’,9),(’j’,10),(’k’,11),(’l’,12),(’m’,13),(’n’,14),(’o’,15),
(’p’,16),(’q’,17),(’r’,18),(’s’,19),(’t’,20),(’u’,21),(’v’,22),
(’w’,23),(’x’,24),(’y’,25),(’z’,26)]
unzip (zip "abcdefg" [1..]) ----> ("abcdefg",[1,2,3,4,5,6,7])
Für weitere Funktionen auf Listen siehe Prelude der Implementierung von
Haskell bzw. Dokumentation in Handbüchern oder in www.haskell.org.
12 wenn
ist.
import Random14 im File steht und der implementierte Modul Random14 installiert
Praktische Informatik 1, WS 2004/05, Kapitel 2, vom 25.11.2004
2.9.1
76
Ressourcenbedarf von Listenfunktionen
Beim Abschätzen des Ressourcenbedarfs von Listenfunktionen muss man sich
darauf festlegen, welche Auswertung in Haskell man meint. Es gibt drei Möglichkeiten:
Komplette Auswertung Auswertung der gesamten Ergebnis-Liste und auch
der Elemente. Das entspricht der Auswertung, die man im Interpreter
macht, da die Anforderung ist, die gesamte Liste zu drucken.
Rückgrat-Auswertung Auswertung nur der Listen-Konstruktoren, des Rückgrats der Liste, aber nicht der Elemente, d.h. gerade so viel, wie die Funktion length von der Liste benötigt.
Kopf-Auswertung : Auswertung zur WHNF. D.h. werte nur soviel aus, dass
die Frage Ist die Liste leer oder nicht leer “ beantwortet werden kann.
”
Beispiel 2.9.1 Länge einer Liste:
lengthr []
= 0
lengthr (x:xs) = 1+lengthr xs
length_lin xs
= length_linr 0 xs
length_linr s []
= s
length_linr s (x:xs) = (length_linr $! (s+1)) xs
Der Ausdruck length lst für eine bereits ausgewertete Liste der Länge n
benötigt O(n) Reduktionsschritte. Der benötigte Zwischenspeicher ist für die
nicht-iterative Version ebenfalls O(n), da die Berechnung des Endergebnisses
erst erfolgt, wenn die Liste zu Ende abgearbeitet wurde. Für die iterative Version der Funktion length ist der Bedarf an Zwischenspeicher konstant, d.h.
O(1).
Beispiel 2.9.2 Der Ausdruck xs ++ ys für zwei ausgewertete Listen der Länge
|xs| und |ys| benötigt O(|xs|+|ys|) Reduktionsschritte, wenn man nur das Rückgrat der Ergebnis-Liste auswertet. Der benötigte Zwischenspeicher ist in diesem
Fall O(|xs|), da nur das Rückgrat von xs kopiert werden muss.
Beispiel 2.9.3 Der Ausdruck map f xs für eine ausgewertete Liste der Länge
n benötigt O(n) Reduktionsschritte, wenn man nur das Rückgrat der Liste auswertet.
Wenn man auch alle Elemente der Ergebnisliste auswertet, hängt der Aufwand
von f ab. Der Speicherbedarf ist im Fall der Rückgratauswertung O(n), da das
Rückgrat der Liste kopiert wird, im Fall der Auswertung aller Elemente hängt
der Aufwand ebenfalls von f ab.
Beispiel 2.9.4 Der Ausdruck concat xs für eine ausgewertete Liste von Listen xs, wobei das i-te Element eine Liste mit ni Elementen ist, benötigt folgende
Anzahl Reduktionsschritte: n1 + 1 + n2 + . . . nm + 1, d.h. Anzahl der Elemente
77
Praktische Informatik 1, WS 2004/05, Kapitel 2, vom 25.11.2004
in der Eingabeliste + Anzahl der Elemente in der Ergebnisliste, wenn man nur
das Rückgrat der Liste auswertet.
Der Speicherbedarf ist im Fall der Rückgratauswertung fast genauso hoch,
wobei die letzte Liste nicht kopiert wird, und somit nicht berücksichtigt werden
muss.
2.9.2
Listenausdrücke, List-Comprehensions
Dies ist eine Spezialität von Haskell und erleichtert die Handhabung von Listen.
Die Syntax ist analog zu Mengenausdrücken, nur dass die Reihenfolge der Abarbeitung der Listenelemente in den Argumentlisten eine Rolle spielt, und die
Reihenfolge der Listenelemente in der Ergebnisliste dadurch festgelegt ist.
Syntax:
[hAusdrucki |“ hGeneratori | hFilteri{, {hGeneratori | hFilteri}}∗ ].
”
Vor dem senkrechten Strich |“kommt ein Ausdruck, danach eine mit Kom”
ma getrennte Folge von Generatoren der Form v <- liste oder von Prädikaten.
Wirkungsweise: die Generatoren liefern nach und nach die Elemente der Listen.
Wenn alle Prädikate zutreffen, wird ein Element entsprechend dem Ausdruck
links von | in die Liste aufgenommen. Hierbei können auch neue lokale Variablen
eingeführt werden, deren Geltungsbereich im Resultatausdruck ist und rechts
von der Einführung liegt, aber noch in der Klammer [. . .].
Beispiel 2.9.5
[x
| x <- xs]
[f x | x <- xs]
[x
| x <- xs, p x]
ergibt die Liste selbst
ist dasselbe wie
map f xs
ist dasselbe wie
filter p xs
[(x,y) | x <- xs, y <-ys]
kartesisches Produkt
der endlichen Listen
[y | x <- xs, y <-x]
xs
und
ys
entspricht der Funktion concat xs
Spezielle Listenausdrücke sind:
• [n..m] erzeugt eine Liste [n, n+1,..., m]
• [n,m..e] erzeugt eine Liste [n, m, n+2*(m-n), ..., e’] mit e’ ≤ e.
• [n..] erzeugt die (potentiell unendliche) Liste [n,n+1,n+2,...].
Beispiel 2.9.6 [(x,y) | x <- [1..10], even x, y <- [2..6], x < y]
Resultat: [(2,3),(2,4),(2,5),(2,6),(4,5),(4,6)]
Die Erzeugungsreihenfolge tabellarisch aufgelistet ergibt:
78
Praktische Informatik 1, WS 2004/05, Kapitel 2, vom 25.11.2004
x
y
?
1
N
2
2
N
2
3
Y
2
4
Y
2
5
Y
2
6
Y
3
N
4
2
N
4
3
N
4
4
N
4
5
Y
4
6
Y
5
N
6
2
N
...
...
...
Ein weiteres Beispiel, das zeigt, dass die Elementvariable auch in der Listenerzeugung weiter rechts verwendet werden kann:
[(x,y) | x <- [1..10], y <- [1..x]]
[(1,1),
(2,1),(2,2),
(3,1),(3,2),(3,3),
(4,1),(4,2),(4,3),(4,4),
(5,1),(5,2),(5,3),(5,4),(5,5),
(6,1),(6,2),(6,3),(6,4),(6,5),(6,6),
(7,1),(7,2),(7,3),(7,4),(7,5),(7,6),(7,7),
(8,1),(8,2),(8,3),(8,4),(8,5),(8,6),(8,7),(8,8),
(9,1),(9,2),(9,3),(9,4),(9,5),(9,6),(9,7),(9,8),(9,9),
(10,1),(10,2),(10,3),(10,4),(10,5),(10,6),(10,7),(10,8),(10,9),(10,10)]
Ein weiteres Beispiel:
[1 | x <- [1..11]]
ergibt [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
2.9.3
Datentypen
Stack Ein Stack (bzw. Keller, Stapel) ist eine oft verwendete Datenstruktur.
Man kann sie benutzen wie einen Stapel, auf den man nach und nach
etwas drauflegen kann, und auch wieder runternehmen kann, aber man
darf nicht mitten im Stapel etwas hinzufügen oder wegnehmen, d.h. man
muss die Reihenfolge einhalten. Man sagt auch “last-in first-out“ (lifo).
Normalerweise verwendet man die abstrakten Operatoren push, pop zur
Bearbeitung eines Stacks. Die Operation push legt etwas auf den Stapel,
und pop entfernt das oberste Element. Es tritt ein Fehler auf, wenn man
vom leeren Stapel etwas herunternehmen will.
In Haskell kann man das sehr gut mit Listen implementieren.
Beispiel 2.9.7 Ein Beispiel, das sich eines Stacks bedienen, ist das effiziente Umdrehen einer Liste in Haskell:
reverse xs = foldl (\x y -> y:x) [] xs
Der Start der Verarbeitung ist mit einem leeren Stack []. Die Elemente
werden einzeln von der Liste xs genommen und auf diesen Stack gelegt.
Wenn die Liste leer ist, entspricht der Stack genau die Elemente der Liste
xs in umgedrehter Reihenfolge.
Praktische Informatik 1, WS 2004/05, Kapitel 2, vom 25.11.2004
79
Queue Eine Schlange, Queue ist eine Datenstruktur, die sich wie eine Warteschlange verhält: Vorne wird bedient (abgearbeitet), und hinten stellen
sich Leute an (first-in first out, fifo).
Dies kann man mit Listen implementieren, allerdings ist dann entweder das
Hinzufügen oder das Entfernen nicht effizient. Es gibt allerdings in Haskell
Implementierungstechniken, die auch Schlangen effizient verwalten.
q_neu x q = x:q
q_weg q = (qletztes q, qrest q)
qletztes [ ]
= error "Schlange ist leer"
qletztes [x]
= x
qletztes (x:xs) = qletztes xs
qrest [x] = []
qrest (x:xs) = x: qrest xs
Assoziationsliste Ist eine Liste von Index-Wert Paaren. Hier kann man zu
Schlüsseln die zugehörigen Werte verwalten. Typische Funktionen dazu
sind: Eintragen, Löschen, Ändern, Abfragen.
, Z.B. kann man zu den Zahlzeichen die zugehörigen Werte sich in einer
Assoziationsliste speichern: [(’0’,0), (’1’,1), ... , (’9’,9)].
mkassocl = []
ascfinde x [] = Nothing
ascfinde x ((xk,xw):rest) =
if x == xk
then Just xw
else ascfinde x rest
asceinf x w [] = [(x,w)]
asceinf x w ((xk,xw):rest) =
if x == xk
then ((xk,w):rest)
else (xk,xw) : (asceinf x w rest)
ascremove x [] = []
ascremove x ((xk,xw):rest) =
if x == xk
then rest
else (xk,xw):(ascremove x rest)
Diese wird benutzt z.B. bei der Erstellung von Array in Haskell.
80
Praktische Informatik 1, WS 2004/05, Kapitel 2, vom 25.11.2004
2.10
Felder, Arrays
Felder (arrays, Vektoren) sind eine weitverbreitete Datenstruktur in Programmiersprachen, die normalerweise im einfachsten Fall eine Folge ai , i = 0, . . . , n
modellieren.
Als Semantik eines Feldes A mit den Grenzen (a, b) und Elementen des Typs
α kann man eine Funktion nehmen: fA : [a, b] → α. In Haskell sind Felder als
Zusatzmodul verfügbar. Die Elemente müssen gleichen Typ haben.
In Haskell gibt es als eingebaute Datenstruktur (als Modul) Arrays. Dies
sind Felder von Elementen des gleichen Typs. Die Implementierung eines Feldes
als Liste ist möglich, hätte aber als Nachteil, dass der Zugriff auf ein bestimmtes
Element, wenn der Index bekannt ist, die Größe O(länge(liste)) hat.
Als Spezialität kann man beim Erzeugen verschiedene Typen des Index
wählen, so dass nicht nur Zahlen, sondern auch Tupel (auch geschachtelte Tupel) von ganzen Zahlen möglich sind, womit man (mehrdimensionale) Matrizen
modellieren kann. Einige Zugriffsfunktionen sind:
• array x y : erzeugt ein Feld (array) mit den Grenzen x = (start, ende),
initialisiert anhand der Liste y, die eine Liste von Index-Wert Paaren (eine
Assoziationsliste) sein muss
• listArray x y: erzeugt ein array mit den Grenzen x = (start, ende),
initialisiert sequentiell anhand der Liste y.
• (!): Infix funktion: ar!i ergibt das i-te Element des Feldes ar.
• (//): Infix-funktion a // xs ergibt ein neues Feld, bei dem die Elemente
entsprechend der Assoziationsliste xs abgeändert sind.
• bounds: Erlaubt es, die Indexgrenzen des Feldes zu ermitteln.
Beispiel 2.10.1 umdrehen_array ar =
let (n,m) = bounds ar
mplusn = m+n
in ar // [(i,ar!(mplusn -i))
| i <- [n..m] ]
Beispiel 2.10.2 Transponieren einer Matrix:
transpose_matrix ar =
let ((n1,m1),(n2,m2)) = bounds ar
assocl = [((i,j), ar!(j,i)) | j <- [n1..n2], i <- [m1..m2]]
in array ((m1,n1), (m2,n2)) assocl
Beispiele kompliziertere Funktionen, die Arrays verwenden, sind in den Kapiteln
zu Python.
Praktische Informatik 1, WS 2004/05, Kapitel 2, vom 25.11.2004
2.11
81
Kontrollstrukturen, Iteration in Haskell
Wir können auch Kontrollstrukturen wie while, until und for definieren, wobei das Typsystem bestimmte Einschränkungen für den Rumpf macht.
Hier zeigt sich ein Vorteil von Haskell: man kann Kontrollstrukturen als
Funktionen definieren, es ist unnötig, diese in die Sprache einzubauen.
Generell ist Rekursion allgemeiner als Iteration, allerdings sind vom theoretischen Standpunkt aus beide gleichwertig: wenn man den Speicher erweitern
kann (d.h. beliebig große Datenobjekte verwenden und aufbauen kann), dann
kann Iteration die Rekursion simulieren.
Die Definition dieser Kontrollstrukturen benutzt einen Datentyp Umge”
bung“, so dass jeder Iterationsschritt die alte Umgebung als Eingabe hat, und
die neue Umgebung als Ausgabe, die dann wieder als Eingabe für den nächsten
Iterationsschritt dient. Diesen Effekt kann man auch beschreiben als: die Umgebung wird in jedem Iterationsschritt geändert. Der Rumpf ist eine Funktion,
die genau diesen Schritt beschreibt.
Beispiel 2.11.1
while:: (a -> Bool) -> (a -> a) -> a -> a
-while test f init
-a: Typ der Umgebung
-test: Test, ob While-Bedingung erfuellt
-f:: a -> a
Rumpf, der die Umgebung abaendert
-init:
Anfangsumgebung
while test f init =
if test init
then while test f (f init)
else init
untill :: (a -> Bool) -> (a -> a) -> a -> a
untill test f init =
if test init
then init
else untill test f (f init)
for :: (Ord a, Num a) => a -> a -> a -> (b -> a -> b) -> b -> b
-For laeuft von start bis end in Schritten von schritt
-Umgebung:: b, Zaehler:: a
-f: Umgebung , aktueller Zaehler -> neue Umgebung
-f : init start -> init’
for start end schritt f init =
if start > end
then init
else let startneu = start + schritt
in for startneu end schritt f (f init start)
82
Praktische Informatik 1, WS 2004/05, Kapitel 2, vom 25.11.2004
Die Funktion f, die als Argument mit übergeben wird, erzeugt ein neues Datenobjekt.
Verwendung im Beispiel:
dreinwhile n = while (> 1) dreinschritt n
dreinschritt x = if x == 1 then 1
else if geradeq x then x ‘div‘ 2
else 3*x+1
-berechnet 1 + 2+ ... + n:
summebis n = for 1 n 1 (+) 0
-berechnet fibonacci (n)
fib_for n = fst (for 1 n 1 fib_schritt
fib_schritt (a,b) _ = (a+b,a)
(1,1))
Ein Ausdruck mit foldl läßt sich als while-Ausdruck schreiben:
foldl f e xs
ist äquivalent zu:
fst (while (\(res,list) -> list /= [])
(\(res,list) -> (f res (head list), tail list))
(e,xs))
Ein Ausdruck mit foldr läßt sich nicht so ohne weiteres als while-Ausdruck
schreiben. Für endliche Listen ist das (unter Effizienzverlust) möglich:
foldr f e xs
ist äquivalent (für endliche Listen) zu:
fst (while (\(res,list) -> list /= [])
(\(res,list) -> (f (head list) res, tail list))
(e,reverse xs))
2.12
Ein-Ausgabe in Haskell
Wir geben eine Kurzeinführung:
Haskell-Programme haben die Möglichkeit, über Aktionen mit der Außenwelt zu kommunizieren. Diese Aktionen sind nur über einen speziellen, vorgegebenen, aber parametrisierbaren Typ IO a möglich. Die Typen dieser Aktionen
haben alle die Form IO a oder a1 -> a2 -> ... -> IO a, wobei ai und a
nicht-IO-Typen sind.
Praktische Informatik 1, WS 2004/05, Kapitel 2, vom 25.11.2004
83
Die Aktionen vermitteln zwischen der Außenwelt und der Auswertung von
Ausdrücken. Die eigentliche funktionale Berechnung hat keine Ein/AusgabeMöglichkeit.
Ein/Ausgabe wird durchgeführt, indem man einen Ausdruck vom Typ IO a
auswertet. Hierbei ist a der Typ des eingegebenen Objekts. Falls eine Ausgabe
gemacht wird, ist das zugehörige Objekt und der Typ nur an den Argumenten
der von Haskell vorgegebenen und verwendeten IO-Aktionen erkennbar.
Einen Ausdruck vom Typ IO a kann man z.B. dadurch konstruieren, dass man
eine IO-Aktion mit Typ b -> IO a auf einen Ausdruck vom Typ b anwendet.
Hierbei sind die Basis-IO-Aktionen vordefiniert, und a, b sind Typen ohne
IO-Untertypen. Im Typ IO a bezieht sich a als Argument des IO immer auf
eine Eingabe. Wenn man kein Resultat einer Aktion hat, d.h. keine Eingabe ins
Programm, dann ist der Typ IO ().
Man kann eigene IO-Aktionen definieren, aber nur indem man einen Satz
von bereits vordefinierten IO-Aktionen kombiniert. Z.B. sind vordefiniert:
putStr:: String -> IO ()
putStrLn:: String -> IO ()
getLine:: IO String
print:: (Show a) => a -> IO ()
readLn: (Read a) => IO a
type FilePath
writeFile ::
appendFile ::
readFile
::
= String
FilePath -> String -> IO ()
FilePath -> String -> IO ()
FilePath
-> IO String
putStr, putStrLn und print sind so definiert, dass diese ihr Argument
gerade ausgeben. Dass gilt aber i.a. nicht bei beliebigen IO-Aktionen.
Die Aktionen print und readLn sind polymorph. Sie sollten im Programm
so eingebettet sein, dass der Typ a vom Typ-Checker ermittelt werden kann. Der
Typ muss dabei ein Show- bzw. Read-Typ sein. Ist der Typ bekannt, dann kann
Haskell intern die richtige Methode, d.h. die dem Typ entsprechende, verwenden
(siehe z.B. getNumber unten).
Beispiel 2.12.1 Das Hello-World-Programm sieht so aus:
*Main> putStr "Hello World"
Hello World*Main>
Kombinieren kann man Aktionen mit der do-Notation, die analog zu einer
Listenkomprehension notiert wird, aber eine andere Wirkung hat.
Beispiel 2.12.2
do {input <- getLine; putStr input}
In Programm kann man das so schreiben:
do input <- getLine
putStr input
84
Praktische Informatik 1, WS 2004/05, Kapitel 2, vom 25.11.2004
Im Interpreter:
*Main> do {input <- getLine;
Hello
-Hello
-*Main>
putStrLn input}
eingetippt
Ausgabe
Die Kombination do ... arg <- ... wirkt wie ein Selektor, der aus einer
IO-Aktion die Werte extrahiert.
Die Bedingung, dass alle Ausdrücke getypt sein müssen und dass man den
Inhalt“ der IO-Aktionen, nämlich den Eingabewert, nur mittels spezieller Selek”
toren im Programm explizit verwenden kann, bewirkt eine starke, aber gewollte
Beschränkung der Programmierung der Ein-Ausgabe: Die referentielle Transparenz wird erhalten. d.h. Funktionen ergeben bei gleichen Argumenten auch
gleiche Werte. Eine spürbare Beschränkung ist der durch das Typsystem und
die Konstruktion und Verwendung des Typs IO erzwungene Programmierstil:
Der Programmierer wird gezwungen, die IO-Aktionen zu sequentialisieren.
Selbst definieren kann man zB die Aktion echoLine
echoLine:: IO String
echoLine = do
input <- getLine
putStr input
Eine Int-Zahl kann man einlesen mittels folgender Aktion
getNumber:: IO Int
getNumber = do
putStr "Bitte eine Zahl eingeben:"
readLn
Beispiel 2.12.3 Mit folgender Definition kann man variable Listen ausgeben:
main = do a <- getNumber
b <- getNumber
print (take a (repeat b))
*Main> main
Bitte eine Zahl eingeben:4
Bitte eine Zahl eingeben:6
[6,6,6,6]
Beispiel 2.12.4 Folgende Aktion bewirkt, dass ein File gelesen und ausgegeben
wird.
fileLesen = do
putStr "File-Name:?"
fname <-getLine
contents<-readFile fname
putStr contents
Praktische Informatik 1, WS 2004/05, Kapitel 2, vom 29.11.2004
2.13
85
Modularisierung in Haskell
Module dienen zur
Strukturierung / Hierarchisierung: Einzelne Programmteile können innerhalb verschiedener Module definiert werden; eine (z. B. inhaltliche) Unterteilung des gesamten Programms ist somit möglich. Hierarchisierung ist
möglich, indem kleinere Programmteile mittels Modulimport zu größeren
Programmen zusammen gesetzt werden.
Kapselung: Nur über Schnittstellen kann auf bestimmte Funktionalitäten zugegriffen werden, die Implementierung bleibt verdeckt. Sie kann somit unabhängig von anderen Programmteilen geändert werden, solange die Funktionalität (bzgl. einer vorher festgelegten Spezifikation) erhalten bleibt.
Wiederverwendbarkeit: Ein Modul kann für verschiedene Programme benutzt (d.h. importiert) werden.
2.13.1
Module in Haskell
In einem Modul werden Funktionen, Datentypen, Typsynonyme, usw. definiert.
Durch die Moduldefinition können diese exportiert Konstrukte werden, die dann
von anderen Modulen importiert werden können.
Ein Modul wird mittels
module Modulname(Exportliste)
 where
Modulimporte,

Datentypdefinitionen,
M odulrumpf

Funktionsdefinitionen, . . .
definiert. Hierbei ist module das Schlüsselwort zur Moduldefinition, Modulname
der Name des Moduls, der mit einem Großbuchstaben anfangen muss. In der
Exportliste werden diejenigen Funktionen, Datentypen usw. definiert, die durch
das Modul exportiert werden, d.h. von außen sichtbar sind.
Für jedes Modul muss eine separate Datei angelegt werden, wobei der Modulname dem Dateinamen ohne Dateiendung entsprechen muss.
Ein Haskell-Programm besteht aus einer Menge von Modulen, wobei eines
der Module ausgezeichnet ist, es muss laut Konvention den Namen Main haben
und eine Funktion namens main definieren und exportieren. Der Typ von main
ist auch per Konvention festgelegt, er muss IO () sein, d.h. eine Ein-/AusgabeAktion, die nichts (dieses Nichts“ wird durch das Nulltupel () dargestellt)
”
zurück liefert. Der Wert des Programms ist dann der Wert, der durch main
definiert wird. Das Grundgerüst eines Haskell-Programms ist somit von der
Form:
module Main(main) where
...
main = ...
...
Praktische Informatik 1, WS 2004/05, Kapitel 2, vom 29.11.2004
86
Im folgenden werden wir den Modulexport und -import anhand folgendes Beispiels verdeutlichen:
Beispiel 2.13.1
module Spiel where
data Ergebnis = Sieg | Niederlage | Unentschieden
berechneErgebnis a b = if a > b then Sieg
else if a < b then Niederlage
else Unentschieden
istSieg Sieg = True
istSieg _
= False
istNiederlage Niederlage = True
istNiederlage _
= False
Modulexport
Durch die Exportliste bei der Moduldefinition kann festgelegt werden, was
exportiert wird. Wird die Exportliste einschließlich der Klammern weggelassen, so werden alle definierten, bis auf von anderen Modulen importierte, Namen exportiert. Für Beispiel 2.13.1 bedeutet dies, dass sowohl die Funktionen
berechneErgebnis, istSieg, istNiederlage als auch der Datentyp Ergebnis
samt aller seiner Konstruktoren Sieg, Niederlage und Unentschieden exportiert werden. Die Exportliste kann folgende Einträge enthalten:
• Ein Funktionsname, der im Modulrumpf definiert oder von einem anderem
Modul importiert wird. Operatoren, wie z.B. + müssen in der Präfixnotation, d.h. geklammert (+) in die Exportliste eingetragen werden.
Würde in Beispiel 2.13.1 der Modulkopf
module Spiel(berechneErgebnis) where
lauten, so würde nur die Funktion berechneErgebnis durch das Modul
Spiel exportiert.
• Datentypen die mittels data oder newtype definiert wurden. Hierbei gibt
es drei unterschiedliche Möglichkeiten, die wir anhand des Beispiels 2.13.1
zeigen:
– Wird nur Ergebnis in die Exportliste eingetragen, d.h. der Modulkopf würde lauten
module Spiel(Ergebnis) where
so wird der Typ Ergebnis exportiert, nicht jedoch die Datenkonstruktoren, d.h. Sieg, Niederlage, Unentschieden sind von außen
nicht sichtbar bzw. verwendbar.
Praktische Informatik 1, WS 2004/05, Kapitel 2, vom 29.11.2004
87
– Lautet der Modulkopf
module Spiel(Ergebnis(Sieg, Niederlage))
so werden der Typ Ergebnis und die Konstruktoren Sieg
und Niederlage exportiert, nicht jedoch der Konstruktor
Unentschieden.
– Durch den Eintrag Ergebnis(..), wird der Typ mit sämtlichen Konstruktoren exportiert.
• Typsynonyme, die mit type definiert wurden, können exportiert werden,
indem sie in die Exportliste eingetragen werden, z.B. würde bei folgender
Moduldeklaration
module Spiel(Result) where
... wie vorher ...
type Result = Ergebnis
der mittels type erzeugte Typ Result exportiert.
• Schließlich können auch alle exportierten Namen eines importierten Moduls wiederum durch das Modul exportiert werden, indem man module
Modulname in die Exportliste aufnimmt, z.B. seien das Modul Spiel wie
in Beispiel 2.13.1 definiert und das Modul Game als:
module Game(module Spiel, Result) where
import Spiel
type Result = Ergebnis
Das Modul Game exportiert alle Funktionen, Datentypen und Konstruktoren, die auch Spiel exportiert sowie zusätzlich noch den Typ Result.
Modulimport
Die exportierten Definitionen eines Moduls können mittels der import Anweisung in ein anderes Modul importiert werden. Diese steht am Anfang des Modulrumpfs. In einfacher Form geschieht dies durch
import Modulname
Durch diese Anweisung werden sämtliche Einträge der Exportliste vom Modul mit dem Namen Modulname importiert, d.h. sichtbar und verwendbar.
Will man nicht alle exportierten Namen in ein anderes Modul importieren,
so ist dies auf folgende Weisen möglich:
Explizites Auflisten der zu importierenden Einträge: Die importierten
Namen werden in Klammern geschrieben aufgelistet. Die Einträge werden hier genauso geschrieben wie in der Exportliste.
Z.B. importiert das Modul
Praktische Informatik 1, WS 2004/05, Kapitel 2, vom 29.11.2004
88
module Game where
import Spiel(berechneErgebnis, Ergebnis(..))
...
nur die Funktion berechneErgebnis und den Datentyp Ergebnis
mit seinen Konstruktoren, nicht jedoch die Funktionen istSieg und
istNiederlage.
Explizites Ausschließen einzelner Einträge: Einträge können vom Import
ausgeschlossen werden, indem man das Schlüsselwort hiding gefolgt von
einer Liste der ausgeschlossen Einträge benutzt.
Den gleichen Effekt wie beim expliziten Auflisten können wir auch im
Beispiel durch Ausschließen der Funktionen istSieg und istNiederlage
erzielen:
module Game where
import Spiel hiding(istSieg,istNiederlage)
...
Die importierten Funktionen sind sowohl mit ihrem (unqualifizierten)
Namen ansprechbar, als auch mit ihrem qualifizierten Namen: Modulname.unqualifizierter Name, manchmal ist es notwendig den qualifizierten Namen
zu verwenden, z.B.
module A(f) where
f a b = a + b
module B(f) where
f a b = a * b
module C where
import A
import B
g = f 1 2 + f 3 4 -- funktioniert nicht
führt zu einem Namenskonflikt, da f mehrfach (in Modul A und B) definiert
wird.
Prelude> :l C.hs
ERROR C.hs:4 - Ambiguous variable occurrence "f"
*** Could refer to: B.f A.f
Werden qualifizierte Namen benutzt, wird die Definition von g eindeutig:
module C where
import A
import B
g = A.f 1 2 + B.f 3 4
89
Praktische Informatik 1, WS 2004/05, Kapitel 2, vom 29.11.2004
Durch das Schlüsselwort qualified sind nur die qualifizierten Namen sichtbar:
module C where
import qualified A
g = f 1 2
-- f ist nicht sichtbar
Prelude> :l C.hs
ERROR C.hs:3 - Undefined variable "f"
Man kann auch lokale Aliase für die zu importierenden Modulnamen angeben, hierfür gibt es das Schlüsselwort as, z.B.
import LangerModulName as C
Eine durch LangerModulName exportierte Funktion f kann dann mit C.f aufgerufen werden.
Abschließend eine Übersicht: Angenommen das Modul M exportiert f und g,
dann zeigt die folgende Tabelle, welche Namen durch die angegebene importAnweisung sichtbar sind:
Import-Deklaration
import M
import M()
import M(f)
import qualified M
import qualified M()
import qualified M(f)
import M hiding ()
import M hiding (f)
import qualified M hiding ()
import qualified M hiding (f)
import M as N
import M as N(f)
import qualified M as N
definierte Namen
f, g, M.f, M.g
keine
f, M.f
M.f, M.g
keine
M.f
f, g, M.f, M.g
g, M.g
M.f, M.g
M.g
f, g, N.f, N.g
f, N.f
N.f, N.g
Hierarchische Modulstruktur
Diese Erweiterung ist nicht durch den Haskell-Report festgelegt, wird jedoch
von GHC und Hugs unterstützt13 . Sie erlaubt es Modulnamen mit Punkten zu
versehen. So kann z.B. ein Modul A.B.C definiert werden. Allerdings ist dies
eine rein syntaktische Erweiterung des Namens und es besteht nicht notwendigerweise eine Verbindung zwischen einem Modul mit dem Namen A.B und
A.B.C.
Die Verwendung dieser Syntax hat lediglich Auswirkungen wie der Interpreter nach der zu importierenden Datei im Dateisystem sucht: Wird import A.B.C
13 An der Standardisierung der hierarchischen Modulstruktur wird gearbeitet, siehe
http://www.haskell.org/hierarchical-modules
Praktische Informatik 1, WS 2004/05, Kapitel 2, vom 29.11.2004
90
ausgeführt, so wird das Modul A/B/C.hs geladen, wobei A und B Verzeichnisse
sind.
Die Haskell Hierarchical Libraries14“ sind mithilfe der hierarchischen Mo”
dulstruktur aufgebaut, z.B. sind Funktionen, die auf Listen operieren, im Modul
Data.List definiert.
14 siehe
http://www.haskell.org/ghc/docs/latest/html/libraries
Herunterladen