Praktikum Funktionale Programmierung - Goethe

Werbung
Begleitmaterial zum
Praktikum Funktionale Programmierung
Sommersemester 2011
Dr. David Sabel
Institut für Informatik
Fachbereich Informatik und Mathematik
Goethe-Universität Frankfurt am Main
Postfach 11 19 32
D-60054 Frankfurt am Main
Email: [email protected]
Stand: 4. April 2011
Inhaltsverzeichnis
1
Concurrent Versions System
1.1 Zugriff per ssh . . . . . . . . . . . . . . . . .
1.2 Arbeitskopie vom Server holen . . . . . . . .
1.3 Arbeitskopie lokal aktualisieren . . . . . . .
1.4 Dateien einchecken . . . . . . . . . . . . . . .
1.5 Hinzufügen von Dateien und Verzeichnissen
1.6 Keyword-Substitution und Binäre Dateien .
1.7 Graphische Oberflächen für CVS . . . . . . .
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
2
2
3
3
3
4
4
4
2
Debugging
5
3
Haddock – A Haskell Documentation Tool
3.1 Dokumentation einer Funktionsdefinition . . . . . . . . . . . .
3.2 Haddock aufrufen . . . . . . . . . . . . . . . . . . . . . . . . .
7
7
8
4
Parser und Parsergeneratoren
4.1 Parser und Syntaxanalyse . . . . . . . . .
4.1.1 Was ist ein Parser? . . . . . . . . .
4.1.2 Beispiel: einfacher Taschenrechner
4.2 Parsergeneratoren . . . . . . . . . . . . . .
4.2.1 Was ist ein Parsergenerator? . . .
4.2.2 Lexikalische Analyse . . . . . . . .
4.3 Happy . . . . . . . . . . . . . . . . . . . .
4.3.1 Aufbau eines Happy-Skripts . . .
.
.
.
.
.
.
.
.
9
9
9
10
12
12
12
13
14
5
Datentypen und Typklassen in Haskell
5.1 Datentypdefinition . . . . . . . . . . . . . . . . . . . . . . . . .
5.2 Typklassen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
5.3 Record-Syntax für Haskell data-Deklarationen . . . . . . . . .
19
19
21
23
6
Modularisierung in Haskell
26
D. Sabel, FP-PR, SoSe 2011
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
Stand: 4. April 2011
Inhaltsverzeichnis
6.1
7
8
Module in Haskell . . . . . . . . . .
6.1.1 Modulexport . . . . . . . . .
6.1.2 Modulimport . . . . . . . . .
6.1.3 Hierarchische Modulstruktur
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
26
28
29
32
.
.
.
.
.
34
36
37
39
40
41
Concurrent Haskell
8.1 Erzeuger / Verbraucher-Implementierung mit 1-Platz Puffer
8.2 Das Problem der Speisenden Philosophen . . . . . . . . . . .
8.3 Futures . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
45
50
50
53
I/O in Haskell
7.1 Primitive I/O-Operationen . . . . . . . . .
7.1.1 Komposition von I/O-Aktionen . .
7.1.2 Einige gebräuchliche IO-Funktionen
7.2 Monaden . . . . . . . . . . . . . . . . . . . .
7.3 Verzögern innerhalb der IO-Monade . . . .
D. Sabel, FP-PR, SoSe 2011
1
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
Stand: 4. April 2011
1
Concurrent Versions System
CVS ist ein Software-System zur Versionsverwaltung von Dateien insbesondere für den Mehrbenutzerbetrieb. Hierbei werden die Dateien zentral auf
einem Server in einem so genannten Repository gespeichert. Die Benutzer
greifen auf die Dateien über einen CVS-Client zu und erhalten ihre lokale
Arbeitskopie. Beim so genannten Einchecken lädt der Benutzer seine geänderten Dateien auf den Server. Wurde zwischenzeitlich die Datei auf dem
Server geändert, so versucht CVS die Änderungen nachzuziehen, d.h. die
beiden Dateien zu “mergen”. Kommt es hierbei zu Konflikten, so muss der
Benutzer diese per Hand beheben.
1.1 Zugriff per ssh
Um sichere Verbindungen zu verwenden, ist es möglich per ssh auf einen
CVS-Server zuzugreifen. Bei Unix/Linux-System ist dafür notwendig, dass
die Umgebungsvariable CVS_RSH auf den Wert ssh gesetzt ist. Verwendet
man z.B. die Bash-Shell, so sollte in der Konfigurationsdatei .bashrc ein
Eintrag der Art
export CVS_RSH=ssh
stehen. Genauere Informationen zum Zugang zum CVS-Server werden während der ersten Besprechung bekannt gegeben.
D. Sabel, FP-PR, SoSe 2011
Stand: 4. April 2011
1.2 Arbeitskopie vom Server holen
1.2 Arbeitskopie vom Server holen
Um eine Arbeitskopie vom Repository auf den lokalen Rechner zu laden,
sollte man cvs get verwenden, wobei der Server, das Repository, den UserNamen und das auzucheckende Modul spezifiziert werden müssen, in der
Form:
cvs -d user@hostname:repositorypfad get modulname
Beim Zugang über ssh muss man anschließend sein Passwort eingeben
und erhält dann eine Arbeitskopie.
1.3 Arbeitskopie lokal aktualisieren
Um eine Arbeitskopie lokal zu aktualisieren, also Änderungen vom Server in
die lokale Kopie einzuspielen, kann man cvs update benutzen. Es empfiehlt
sich die Option -d zu verwenden, die auch neu hinzugefügte Verzeichnis
herunter lädt. D.h. wenn man sich innerhalb der Arbeitskopie befindet:
cvs update -d
1.4 Dateien einchecken
Um die eigenen Änderungen auf den CVS-Server hochzuladen, ist das Kommando cvs commit zu verwenden, welches zusätzlich mit der Option -m
aufgerufen werden sollte, die es erlaubt noch einen Kommentar bezüglich der Änderungen anzugeben (was wurde geändert, warum). Ruft man
cvs commit ohne Dateinamen aus, so werden alle geänderten Dateien hochgeladen. Noch zwei Beispiele:
cvs commit -m "Programm verbessert, Bug XYZ entfernt" datei.hs
lädt die Änderungen an der Datei datei.hs auf den Server.
cd tmp
cvs commit -m ""
lädt alle Änderungen an Dateien ab dem Verzeichnis tmp auf den CVSServer.
D. Sabel, FP-PR, SoSe 2011
3
Stand: 4. April 2011
1 Concurrent Versions System
1.5 Hinzufügen von Dateien und Verzeichnissen
Um Dateien oder Verzeichnisse hinzu zu fügen, reicht es nicht ein
cvs commit auf diese Datei zu machen. Man erhält dann die Fehlermeldung
cvs commit: nothing known about ‘dateiname’
Zum Hinzufügen muss das Kommando cvs add verwendet werden. Hiermit werden Verzeichnisse sofort hinzugefügt (allerdings nicht deren Inhalt!).
Dateien werden zwar hinzugefügt, aber nach dem Hinzufügen muss noch
ein cvs commit auf die Datei erfolgen. Ein Beispiel:
cvs add tmp/
cvs add datei.hs
cvs commit -m "" datei.hs
1.6 Keyword-Substitution und Binäre Dateien
Da CVS in Dateien Schlüsselwörter ersetzt (z.B. wird $Date$ durch das Datum des letzten Eincheckens ersetzt) muss man beim Einchecken von binären Dateien aufpassen, da dort ja kein Ersetzen erfolgen sollte. Hierfür
sollte man die Option -kb beim cvs add verwenden, z.B.
cvs add -kb anleitung.pdf
Man kann diese Option auch noch später setzen mit cvs admin und anschließendem cvs update.
1.7 Graphische Oberflächen für CVS
Neben den hier vorgestellten Kommandos für die Konsole gibt es
zahlreiche graphische Oberflächen zum Benutzen von CVS. Z.B. verfügt die IDE Eclipse (http://www.eclipse.org/) bereits über eingebaute CVS-Unterstützung. Unter KDE gibt es das Programm cervisia
(http://cervisia.kde.org/), für MS Windows empfiehlt sich das Programm
TortoiseCVS (http://www.tortoisecvs.org/)
Stand: 4. April 2011
4
D. Sabel, FP-PR, SoSe 2011
2
Debugging
Haskell stellt nicht viele Tools zum Debuggen zur Verfügung. Der Hauptgrund dafür ist das mächtige statische Typsystem, welches bereits zur Compilezeit viele Programmierfehler erkennen lässt. Trotzdem geben wir hier
einige Hinweise zum Debuggen.
In der Standardbibliothek Debug.Trace findet sich die Funktion trace
vom Typ
> trace :: String -> a -> a
Wenn diese aufgerufen wird, gibt sie den String des ersten Arguments aus
und liefert dann als Ergebnis das zweite Argument. Mit dieser Funktion
kann man Zwischenwerte beim Berechnen zum Debuggen ausgeben. In Verbindung mit Guards kann man dies relativ komfortabel bewerkstelligen, ohne den Code mit vielen trace-Aufrufe zu verseuchen. Wir betrachten ein
Beispiel. Sei f definiert als
> f x y z = e
wobei e irgendeinen Code darstellt. Um nun zum Debugging, bei jedem
Aufruf von f die Werte der Argumente auszugeben, kann man wie folgt
vorgehen:
> f x y z
Stand: 4. April 2011
D. Sabel, FP-PR, SoSe 2011
2 Debugging
>
>
| trace (show x ++ show y ++ show y) False = undefined
| otherwise
= e
Der erste Guard wird immer ausgewertet und deshalb werden mittels trace
die Argumente ausgedruckt. Da trace aber insgesamt stets False liefert trifft
der Guard nicht zu und der otherwise-Guard wird immer aufgerufen. Will
man den trace-Aufruf entfernen, so reicht es die zweite Zeile auszukommentieren:
> f x y z
> -- | trace (show x ++ show y ++ show y) False = undefined
> | otherwise
= e
Stand: 4. April 2011
6
D. Sabel, FP-PR, SoSe 2011
3
Haddock – A Haskell Documentation Tool
Haddock (http://haskell.org/haddock) dient zum Erstellen einer HTMLDokumentation anhand speziell kommentierter Haskell-Quellcode-Dateien.
Hierbei wird im Allgemeinen nur für jene Funktionen und Datentypen eine
Dokumentation erstellt, die in der Exportliste eines Moduls vorhanden sind,
und – bei Funktionen – explizit mit einer Typsignatur versehen sind.
Wir gehen in diesem Abschnitt auf einige Grundfunktionalitäten von Haddock ein, die vollständige Dokumentation ist auf oben genannter Webseite
verfügbar.
3.1 Dokumentation einer Funktionsdefinition
Wir betrachten das folgende Beispiel
quadrat :: Integer -> Integer
quadrat x = x * x
Ein Dokumentationsstring für diese Definition kann wie folgt hinzugefügt
werden:
-- | Die ’quadrat’-Funktion quadriert Integer-Zahlen
quadrat :: Integer -> Integer
quadrat x = x * x
Stand: 4. April 2011
D. Sabel, FP-PR, SoSe 2011
3 Haddock – A Haskell Documentation Tool
D.h. Haddock-Kommentare beginnen mit -- | und müssen vor der Typdeklaration stehen. Man beachte, dass bei Verwendung von Literate Haskell,
die Haddock-Kommentare im Code-Teil stehen müssen, d.h. die Definition
hat dann die Form
> -- | Die ’quadrat’-Funktion quadriert Integer-Zahlen
> quadrat :: Integer -> Integer
> quadrat x = x * x
während
| Die ’quadrat’-Funktion quadriert Integer-Zahlen
> quadrat :: Integer -> Integer
> quadrat x = x * x
nicht funktioniert.
3.2 Haddock aufrufen
Man beachte, dass Haddock mit sämtlichen Quelldateien aufgerufen werden
muss, um eine korrekte Verlinkung der Dokumente zu erhalten. Für Dateien
file1, . . . , fileN ist der Haddock-Aufruf
haddock ... --html
-o ausgabe file1 ... fileN
Hierbei bedeutet --html, dass eine HTML-Dokumentation generiert wird,
und -o ausgabe bedeutet, dass die HTML-Dateien im Verzeichnis ausgabe
abgelegt werden (dieses muss vorher vorhanden sein!).
Da sämtliche Quelltextdateien aufeinmal übergeben werden müssen,
lohnt es sich den Haddock-Aufruf zu automatisieren und ein Shell-Skript
(oder unter Windows eine entsprechende Batch-Datei) anzulegen.
Stand: 4. April 2011
8
D. Sabel, FP-PR, SoSe 2011
4
Parser und Parsergeneratoren
Zum automatischen Erstellen eines Parser in Haskell kann der Parsergenerator Happy1 verwendet werden. Dieses Kapitel beschreibt, was ein Parser
ist und motiviert die Verwendung von Parsergeneratoren in der Softwareentwicklung. Am Schluß wird auf die Benutzung des Happy eingegangen.
4.1 Parser und Syntaxanalyse
4.1.1 Was ist ein Parser?
Wer einen Compiler für eine Programmiersprache entwickeln möchte, steht
u.a. vor der Aufgabe, eine Funktion zu schreiben, welche den Quellcode
auf syntaktische Korrektheit überprüft. Der Quellcode ist zunächst nichts
anderes als eine sehr lange Folge von ASCII-Zeichen ohne inhaltliche Bedeutung. Damit dieser String als ein korrektes Programm erkannt werden
kann, muss die Anordnung der Zeichen gewissen Regeln genügen. Die Gesamtheit dieser Regeln wird als Syntax der Programmiersprache bezeichnet.
Ein Programm, welches die Syntaxanalyse durchführt, nennt sich Parser.
Man kann sich nun viele Beispiele ausdenken, wo auch in anderen Gebieten — also nicht nur im Bereich des Compilerbaus — Parser eingesetzt
1
Verfügbar unter http://haskell.org/happy
Stand: 4. April 2011
D. Sabel, FP-PR, SoSe 2011
4 Parser und Parsergeneratoren
werden können. Ein solches Beispiel ist eben gerade die Überprüfung eines aussagenlogischen Ausdrucks. Ein anderes Beispiel wird im nächsten
Unterabschnitt vorgestellt.
4.1.2 Beispiel: einfacher Taschenrechner
Wir wollen nun an einem einfachen Beispiel sehen, wie man zu einem gegebenen Problem eine exakte Syntaxbeschreibung angibt. Die Notation, welche
wir hier verwenden, nennt sich Backus Naur Form oder kurz: BNF.
Die Syntax wird in Form von Ableitungsregeln angegeben. Dabei stehen auf der linken Seite einer Regel Variablennamen (Nonterminals), auf der
rechten Seite stehen Strings aus weiteren Variablen und Symbolen aus dem
Eingabestrom des Parsers (Terminals). Der Parser versucht nun ausgehend
von einer Startvariablen solange Ableitungen durchzuführen, bis ein String
aus Terminals entstanden ist, der mit dem Eingabestring identisch ist.
Nehmen wir an, unsere Syntax (in BNF) lautete:
S ::= a | aX
X ::= Xb | ε
wobei S das Startsymbol bezeichne, wir davon ausgehen, dass Nonterminals
fett und groß und Terminals klein geschrieben werden, das Zeichen «|»
eine Art „Oder“ und ε das „leere Terminal“ darstellt. Dies ist die Syntax der
formalen Sprache {a, ab, abb, ...}. Die Ableitung des Wortes abb sieht dann z.B.
wie folgt aus:
S → aX → a(bX) → a(b(bX)) → a(b(bε))
Als ein etwas komplexeres Beispiel betrachten wir einen Taschenrechner,
welcher die vier Grundrechenarten beherrscht. Unser erster Ansatz wird
wohl wie folgt lauten:
Stand: 4. April 2011
10
D. Sabel, FP-PR, SoSe 2011
4.1 Parser und Syntaxanalyse
Expr ::= Zahl
|
( Expr )
|
Expr + Expr
|
Expr − Expr
|
Expr ∗ Expr
|
Expr / Expr
Zahl ::= Ziff | Zahl Ziff
Ziff ::= 1 | 2 | ... | 9 | 0
d.h. Zahlen werden aus einzelnen Ziffern gebildet (wir verzichten auf negative Zahlen) und ein Rechenausdruck ist eine Zahl oder ist aus Unterausdrücken der gleichen Form zusammengesetzt. Dies sieht recht einleuchtend
aus, aber es ist nicht wirklich das was wir wollen:
2+5∗3
Dieser Eingabestring läßt sich auf verschiedene Arten ableiten:
Expr → Expr + Expr → ... → 2 + Expr → 2 + (Expr ∗ Expr) → ...
Expr → Expr ∗ Expr → ... → Expr ∗ 3 → (Expr + Expr) ∗ 3 → ...
Die zweite Ableitung entspricht nicht dem, was wir von der Mathematik
her kennen („Punkt-vor-Strich Regel“). Wir werden also diese zusätzlichen
Regeln in irgendeiner Weise in unsere BNF einbauen müssen. Die folgende
Syntax erfüllt die bekannten Präzedenzregeln:
Expr ::= Expr + Term | Expr − Term | Term
Term ::= Term ∗ Fact | Term/Fact | Fact
Fact ::= ( Expr ) | Zahl
Zahl ::= Ziff | Zahl Ziff
Ziff ::= 1 | 2 | ... | 9 | 0
Es braucht ein wenig Übung zu verstehen, wie und warum dies funktioniert.
D. Sabel, FP-PR, SoSe 2011
11
Stand: 4. April 2011
4 Parser und Parsergeneratoren
4.2 Parsergeneratoren
4.2.1 Was ist ein Parsergenerator?
Nachdem man für eine Sprache eine Syntax in Form einer BNF erstellt hat,
geht es an die Umsetzung der Syntax in die Zielsprache. Je nach Komplexität der Syntax wird man diese Aufgabe irgendwo zwischen ‚lästig‘ und
‚unzumutbar‘ einordnen. Auch sieht man dem geschriebenen Quelltext oft
kaum mehr die ursprüngliche Syntax an. An dieser Stelle kommen Parsergeneratoren ins Spiel.
Ein Parsergenerator ist ein Programm, welches als Eingabe eine Syntax in
BNF2 erhält und daraus einen Parser in der Zielsprache — in unserem Fall
also Haskell — erzeugt. Dadurch konzentriert sich die Arbeit im wesentlichen auf das Finden einer Syntax, und die Fehler die auftreten sind i.a.
logischer Natur (keine Programmierfehler).
Der bekannteste Parsergenerator ist Yacc,3 welcher in den 70er Jahren für
die Herstellung der Parser in UNIX C-Compilern entwickelt wurde und
vielen anderen Parsergeneratoren (z.B. dem Happy), zumindest was die Notation betrifft, als Vorbild gedient hat.
4.2.2 Lexikalische Analyse
Wir hatten gesagt, dass es sich bei der Eingabe eines Parsers um einen String
von Zeichen handelt — üblicherweise sind dies ASCII-Zeichen. Dabei gibt
es aber ein paar Dinge zu beachten. Betrachten wir dazu folgenden HaskellText:
> func var = 5 * var
Es ist offenbar unerheblich, ob beispielsweise zwischen den Zeichen 5 und *
kein, ein oder beliebig viele Freizeichen stehen. Auf der anderen Seite ergibt
sich ein völlig anderes Programm, wenn wir schreiben:
> func v ar = 5 * v ar
Wir müssen also unterscheiden zwischen den ASCII-Zeichen, wie sie uns
im Eingabestring begegnen und den Zeichen, die aus der Sicht der Syntax Sinneinheiten bilden. Solche Sinneinheiten sind im obigen Beispiel die
„Zeichen“ func, var, =, 5, * und var. Keine Sinneinheiten sind dagegen die
Zwischenräume und der abschließende Wagenrücklauf (’\n’).4 Man nennt
2
bzw. einer der BNF ähnlichen Notation, wie im Beispiel des Happy
steht für Yet another Compiler Compiler
4
Diese Zeichen werden auch als „Whitespace“ bezeichnet.
3
Stand: 4. April 2011
12
D. Sabel, FP-PR, SoSe 2011
4.3 Happy
diese Sinneinheiten Token — es ist die Aufgabe eines Lexers, aus dem Strom
von ASCII-Zeichen eine Folge von Token zu generieren, um den Parser von
technischen Details zu entlasten. Eine solche Tokenfolge könnte für obiges
Beispiel wie folgt aussehen:5
[BEZEICHNER func, BEZEICHNER var, ZUWEISUNG, INT 5, MULT,
BEZEICHNER var]
Eine lexikalische Analyse ist nun ein Pattern Matching ähnlich dem, wie
wir es von Haskell her kennen. D.h. auf der linken Seite stehen Muster, denen gewisse Folgen von ASCII-Zeichen entsprechen können. Diese Muster
sind reguläre Ausdrücke. Auf der rechten Seite stehen Token, die im Falle
eines Matchings erzeugt werden sollen. Einige Beispiele sollen dies klarmachen:
*
(0-9)+
TokenTimes
TokenInt $$
Die erste Zeile ordnet dem Zeichen ‚*‘ das entsprechende Token TokenTimes zu. In der zweiten Zeile werden Zahlen aus Ziffern erkannt.
$$ steht hierbei für den Teilstring, der gematcht wurde, also z.B. 100.
Das Programm Alex6 ist wie bereits erwähnt ein Lexer-Generator. Da man
aber den ASCII-Strom häufig recht einfach „von Hand“ in den zugehörigen
Tokenstrom überführen kann, wird ein solches Programm seltener benutzt
als ein Parsergenerator.
4.3 Happy
Happy7 ist ein Parsergenerator, dessen Zielsprache Haskell ist. Er hat viel
von Yacc übernommen. Die offizielle Referenz ist (?).
Einen Lexer-Generator gibt es zwar auch für Haskell (Alex), aber es zeigt
sich, dass es gerade in Haskell durch dessen ausgefeiltes Patternmatching
selten nötig ist, eine solche Software zu verwenden. Stattdessen schreibt
man einen Lexer gewöhnlich von Hand als ein eigenständiges Modul und
bindet dieses mit dem Befehl import in das Happy-Skript ein (s. nächsten
Unterabschnitt).
5
wobei wir gleich eine Schreibweise wählen, wie wir sie auch in Haskell benutzen würden,
vorausgesetzt ein Datentyp Token existierte.
6
http://haskell.org/alex/
7
http://haskell.org/happy/
D. Sabel, FP-PR, SoSe 2011
13
Stand: 4. April 2011
4 Parser und Parsergeneratoren
4.3.1 Aufbau eines Happy-Skripts
In diesem Abschnitt werden wir beschreiben, wie eine Parserspezifikation
für Happy aussieht. Hierfür verwenden wir als Beispiel arithmetische Ausdrücke, mit der (mehrdeutigen!) Grammatik:
Expr ::=
|
|
|
|
|
Expr + Expr
Expr − Expr
Expr ∗ Expr
Expr / Expr
( Expr )
Zahl
Die Produktionen für Zahl geben wir nicht an, da wir das eigentlich Parsen
der Zahl dem Lexer überlassen werden.
Jedes Happy-Skript besteht aus bis zu 4 Teilen.
Der erste (optionale) Teil ist ein Block Haskell-Code, der von geschweiften
Klammern umschlossen wird. Dieser Block wird unverändert an den Anfang
der durch Happy generierten Datei gesetzt. Für gewöhnlich stehen hier der
Modulkopf, Typdeklarationen, import-Befehle usw.
{
module Calc where
import Char
}
Der nächste Teil enthält verschiedene Direktiven, die Happy für eine korrekte
Funktionsweise unbedingt benötigt:
• %name NAME bezeichnet den Namen der Parserfunktion. Unter diesem
Namen kann der Parser also später aufgerufen werden.
• %tokentype { TYPE } Dies ist der Ausgabetyp des Lexers und damit
der Eingabetyp des Parsers.
• %token MATCHLIST Hier werden den Token, die vom Lexer erzeugt
wurden, die Terminals zugewiesen, die in der BNF verwendet werden.
Ein Beispiel ist:
%name calculator
%tokentype { Token }
Stand: 4. April 2011
14
D. Sabel, FP-PR, SoSe 2011
4.3 Happy
%token
int
’+’
’-’
’*’
’/’
’(’
’)’
{
{
{
{
{
{
{
TokenInt $$ }
TokenPlus }
TokenMinus }
TokenTimes }
TokenDiv }
TokenOB }
TokenCB }
Der Parser wird somit den Namen calculator erhalten, die verwendeten
Tokens sind vom Datentyp Token und für die Zuweisung der Terminals an
die Tokens gilt: Links stehen die Terminals, rechts in geschweiften Klammern
die Token.
Das Symbol $$ ist ein Platzhalter, das den Wert des Tokens repräsentiert.
Normalerweise ist der Wert eines Tokens der Token selbst, mit $$ wird ein
Teil des Tokens als Wert spezifiziert. Im Beispiel ist der Wert des Tokens
TokenInt zahl die Zahl.
Es schließt sich der Grammatikteil an (vom zweiten Teil durch ein %% getrennt), in dem also in einer BNF ähnlichen Notation die Syntax, wie man sie
sich zuvor überlegt hat, aufgeschrieben wird. Auf die kleinen Unterschiede
zur BNF möchte ich hier nicht eingehen, wichtiger ist, dass man hinter jede
Regel eine so genannte Aktion schreiben kann.
%%
Expr :: { Expr }
Expr : Expr ’+’ Expr
| Expr ’-’ Expr
| Expr ’*’ Expr
| Expr ’/’ Expr
| ’(’ Expr ’)’
| int
{
{
{
{
{
{
Plus $1 $3}
Minus $1 $3}
Times $1 $3}
Div $1 $3 }
$2 }
Number $1}
Hinter den Regeln steht in geschweiften Klammern jeweils ein Stück
Haskell-Code. Dies sind „Aktionen“, die immer dann ausgeführt werden,
wenn diese Regel abgeleitet wird. Mittels $i wird auf den Wert von i-ten
Terminals bzw. Nonterminals zugegriffen. Der Wert eines Terminals ist dabei normalerweise das Terminal selbst. Durch die Aktionen hat der Parser
also eine Ausgabe (und ist nicht nur ein reiner Syntax-Überprüfer). In unserem Beispiel ist die Ausgabe eine Objekt vom Typ Expr. Wie wir nun schon
D. Sabel, FP-PR, SoSe 2011
15
Stand: 4. April 2011
4 Parser und Parsergeneratoren
sehen, werden die Zahlen nicht mittels der Grammatik geparst, sondern
direkt vom Token TokenInt bzw. Terminal int übernommen. Dies ist eine Vereinfachung, d.h. wir überlassen das korrekte Parsen der Zahlen dem
Lexer (er erstellt ja das Token TokenInt).
Der vierte Teil eines Happy-Skripts ist wieder ein in geschweifte Klammern
gesetzter Block mit Haskell-Code, welcher unverändert ans Ende der erzeugten Datei gesetzt wird. Hier muss zumindest die Funktion happyError
stehen, welche im Fall eines Syntax-Fehlers von der Parser-Funktion automatisch angesprungen wird (damit dies funktioniert, darf für diese Funktion
kein anderer Name verwendet werden.) Oft ist hier auch der Lexer implementiert, wenn der Programmierer zu faul war, ihn in ein eigenes Modul zu stecken. Für unser Beispiel müssen auch die Datentypen Expr und
Token sowie der Lexer irgendwo definiert werden, d.h. entweder in einem
der Haskell-Code-Abschnitte der Parserspezifikationsdatei oder in externen
Haskell-Dateien, die dann importiert werden.
Der Vollständigkeit halber, der Rest der der Parserspezifikation für unser
Beispiel:
{
happyError :: [Token] -> a
happyError _ = error "parse error!"
data Token =
|
|
|
|
|
|
data Expr
TokenInt Int
TokenPlus
TokenMinus
TokenTimes
TokenDiv
TokenOB
TokenCB
= Plus
| Minus
| Times
| Div
| Number
deriving(Show)
Expr
Expr
Expr
Expr
Int
Expr
Expr
Expr
Expr
lexer :: String -> [Token]
Stand: 4. April 2011
16
D. Sabel, FP-PR, SoSe 2011
4.3 Happy
lexer [] = []
lexer (’+’:cs)
lexer (’-’:cs)
lexer (’*’:cs)
lexer (’/’:cs)
lexer (’(’:cs)
lexer (’)’:cs)
lexer (c:cs)
| isSpace c =
| isDigit c =
| otherwise =
=
=
=
=
=
=
TokenPlus : lexer cs
TokenMinus : lexer cs
TokenTimes : lexer cs
TokenDiv : lexer cs
TokenOB : lexer cs
TokenCB : lexer cs
lexer cs
lexNum (c:cs)
error ("parse error, can’t lex symbol " ++ show "c")
lexNum cs = TokenInt (read num) : lexer rest
where (num,rest) = span isDigit cs
}
Mit der so erstellten Parserspezifikation (die Dateien haben die Endung
.y bzw .ly falls es sich um ein literate skript handelt), kann nun mittels
happy der Parser generiert werden:
happy example.y
shift/reduce conflicts:
16
Die Meldung der Konflikte sagt uns, dass etwas nicht stimmt. Der erstellte
Parser weiß in manchen Situationen nicht was er tun soll. Der Grund hierfür liegt in der Mehrdeutigkeit unserer Grammatik. Wir könnten nun eine
eindeutige (aber auch komplizierte Grammatik) benutzen, aber happy bietet
uns die Möglichkeit Präzedenz und Assoziativität von Operatoren am Ende
der Direktiven festzulegen. Hierbei gilt
• %left Terminal(e) legt fest, dass diese Terminale links-assoziativ sind
(d.h. ein Ausdruck a ⊗ b ⊗ c wird als (a ⊗ b) ⊗ c aufgefasst).
• %right Terminal(e) legt fest, dass diese Terminale rechts-assoziativ sind
(d.h. ein Ausdruck a ⊗ b ⊗ c wird als a ⊗ (b ⊗ c) aufgefasst).
• %nonassoc Terminal(e) legt fest, dass diese Terminale nicht assoziativ
sind (d.h. ein Ausdruck a ⊗ b ⊗ c kann nicht geparst werden und es
tritt ein Fehler auf)
D. Sabel, FP-PR, SoSe 2011
17
Stand: 4. April 2011
4 Parser und Parsergeneratoren
Die Präzedenz der Terminale gegenüber den anderen Terminalen wird
durch die Reihenfolge %left, %right und %nonassoc Direktiven festgelegt,
wobei „früher“ „weniger Präzedenz“ bedeutet. Nach dem Einfügen der Zeilen
%left ’+’ ’-’
%left ’*’ ’/’
direkt vor %%, hat der Parser keine Konflikte mehr und parst arithmetische
Ausdrücke entsprechend der üblichen geltenden Konventionen (Punkt vor
Strich usw.).
Stand: 4. April 2011
18
D. Sabel, FP-PR, SoSe 2011
5
Datentypen und Typklassen in Haskell
In diesem Kapitel werden in Kurzform selbst definierte Datentypen, Typklassen und die Record-Syntax für Datentypen erläutert.
5.1 Datentypdefinition
Neben primitiven Datentypen, die Haskell bereits bereitstellt, (z.B. für ganze Zahlen (Int für beschränkte Zahlen, Integer für unbeschränkte Zahlen)
für Fließkommazahlen (Double und Float), für Bruchzahlen Rational) und
komplexeren Typen wie Tupel, Listen, Arrays, etc., kann man in Haskell
Datentypen selbst definieren. Hierfür gibt es im Wesentlichen drei verschiedene Möglichkeiten:
• Mit type kann ein Typsynonym definiert werden, d.h. man vergibt einen
neuen Namen für schon definierte Typen. Ein einfaches Beispiel ist:
type Wahrheitswert = Bool
Ein komplexeres Beispiel ist ein Typsynonym für Wörterbücher, welches polymorph über dem Typ der Einträge ist:
type Woerterbuch a = [(Integer,a)]
Stand: 4. April 2011
D. Sabel, FP-PR, SoSe 2011
5 Datentypen und Typklassen in Haskell
• Mit data wird ein neuer Datentyp definiert. Ein einfacher Aufzählungstyp für die RGB-Farben kann z.B. definiert werden mit
data RGB = Rot | Gruen | Blau
Hierbei ist RGB ein neuer Typ und Rot, Gruen, Blau sind neue Datenkonstruktoren. Für diese Datenkonstruktoren kann Pattern-Matching
verwendet werden. Z.B. kann man eine Funktion definieren, die jede
RGB-Farbe in einen String konvertiert:
rgbToString Rot = "rot"
rgbToString Gruen = "gruen"
rgbToString Blau = "blau"
Datentypen können polymorph sein, z.B.
entwederOder a b = Links a | Rechts b
und Datentypen können rekursiv sein. Die in Haskell eingebauten Listen sind rekursive Datentypen. Man könnte diese selbst definieren als
data Liste a = Nil | Cons a (Liste a)
Binäre Bäume mit Blattmarkierungen können rekursiv definiert werden als
data Baum a = Blatt a | Knoten (Baum a) (Baum a)
• Mit newtype wird ein Typsynonym definiert, dass durch einen zusätzlichen Datenkonstruktor verpackt wird. Rein syntaktisch ist newtype
überflüssig, da dies auch stets durch eine data-Deklaration möglich ist.
Das Verwenden der newtype-Syntax hat den Vorteil, dass der Compiler weiß, dass es sich eigentlich nur um ein verpacktes Typsynonym
handelt. Ein weiterer Grund ist, dass für mit type-deklarierte Typsynonyme keine Typklasseninstanzen definiert werden können, während
dies für mit newtype definierte Typen möglich ist.
Stand: 4. April 2011
20
D. Sabel, FP-PR, SoSe 2011
5.2 Typklassen
5.2 Typklassen
Typklassen dienen dazu Funktionsnamen und Operatoren zu überladen, d.h.
den gleichen Funktionsnamen oder Operator für unterschiedliche Typen zu
verwenden. Z.B. kann deshalb in Haskell den Gleichheitstest == sowohl auf
Integerzahlen aber auch auf Listen von Zeichen verwenden. Hierfür ist in
Haskell bereits die Typklasse Eq wie folgt vordefiniert:
class Eq a where
(==), (/=) :: a -> a -> Bool
-- Minimal complete definition:
-(==) or (/=)
x /= y
= not (x == y)
x == y
= not (x /= y)
Dieser Code definiert die Klasse Eq. Für Typen die Instanz dieser Klasse
sind, kann der Gleichheitstest == und der Ungleichheitstest /= verwendet
werden. Innerhalb der Definition sind noch Default-Implementierungen für
== und /= angegeben. Beim Instantiieren eines Typs können diese defaultImplementierungen überschrieben werden. Für die Klasse Eq muss entweder
der Gleichheitstest oder der Ungleichheitstest beim instantiieren überschrieben werden, den jeweils anderen Test erhält quasi „geschenkt“. Die Abhängigkeit von einer Klasseninstanz sieht man auch, wenn man sich den Typ
von == im Interpreter anzeigen lässt:
> :t (==)
(==) :: (Eq a) => a -> a -> Bool
Die Angaben links vom => sind Typklassenbeschränkungen. Das ganze ist zu
lesen als, Für alle Typen a, die Instanzen der Klasse Eq sind, hat == den Typ
a -> a -> Bool.
Eine Instanz der Klasse Eq für den eben definierten Datentypen RGB könnte
man definieren als:
instance Eq (RGB)
(==) Rot Rot
(==) Gruen Gruen
(==) Blau Blau
(==) _ _
D. Sabel, FP-PR, SoSe 2011
where
= True
= True
= True
= False
21
Stand: 4. April 2011
5 Datentypen und Typklassen in Haskell
Manche Typklasseninstanzen können jedoch auch automatisch aufgrund der
Struktur der Datentypdefinition automatisch vom Compiler generiert werden. Hierfür dient das Schlüsselwort deriving. Wir hätten schreiben können:
data RGB = Rot | Gruen | Blau
deriving(Eq)
und uns damit die Angabe der Typklasseninstanz ersparen können, da sie
automatisch generiert wird.
Typklasseninstanzen können nicht für Typsynonyme definiert werden, es
sei denn sie wurden mittels newtype definiert. Z.B. können wir für den Typ
Wahrheitswert mit der Definition
newtype Wahrheitswert = WW Bool
eine Typklasseninstanz für Eq definieren:
instance Eq Wahrheitswert where
(==) (WW a) (WW b) = a == b
Hierbei haben wir den Gleichheitstest einfach auf den Gleichheitstest für
Boolesche Werte zurück geführt.
Weitere oft verwendete Typklassen sind die Klassen Show und Read. Daten
der Typen Instanz der Klasse Show sind können in Strings konvertiert (mit
der Funktion show) und damit angezeigt werden. Umgekehrt können Strings
mit der read-Funktion in Daten eines Types konvertiert werden, wenn der
Typ Instanz der Klasse Read ist.
Da der GHCi die show-Funktion zum Anzeigen von Werten verwendet,
erhält man im Falle, dass keine Klasseninstanz definiert wurde die Fehlermeldung:
<interactive>:1:0:
No instance for (Show (Baum t))
arising from a use of ‘print’ at <interactive>:1:0-25
Possible fix: add an instance declaration for (Show (Baum t))
In a stmt of a ’do’ expression: print it
Typklassen bieten auch eine Möglichkeit der Vererbung, d.h. man kann
fordern, dass eine Instanz einer Typklasse ABC nur dann erlaubt ist, wenn
der Typ bereits Instanz einer anderen Typklasse XYZ ist. Eine solche Klasse
ist die Klasse Num, die bereits fordert, dass der Typ Instanzen für Eq und
Show ist.
Stand: 4. April 2011
22
D. Sabel, FP-PR, SoSe 2011
5.3 Record-Syntax für Haskell data-Deklarationen
class (Eq a, Show a) => Num a where
(+), (-), (*) :: a -> a -> a
negate
:: a -> a
abs, signum
:: a -> a
fromInteger
:: Integer -> a
Dies entspricht quasi einer Mehrfachvererbung: Man kann Num als Subklasse
der beiden Klassen Eq und Show auffassen.
Umgekehrt kann man bei der Instanzbildung auch Bedingungen stellen:
Wir betrachten als Beispiel eine Instanz der Klasse Show für den Typ Baum von
oben. Wir können polymorphe Bäume nur dann sinnvoll anzeigen, wenn wir
bereits wissen wie die Markierungen des Baum angezeigt werden. In der
Instanzdefinition können wir dies wie folgt ausdrücken:
instance Show a => Show (Baum a) where
show (Blatt a) = show a
show (Knoten l r) = "<" ++ show l ++ "|" ++ show r ++ ">"
Diese Bedingung ist zwingend erforderlich, da wir ansonsten den Aufruf show a für die Blattmarkierung nicht durchführen dürfen. Lassen wir
Show a => in obiger Definition weg, dann meldet uns der Compiler wie
erwartet einen Fehler:
Could not deduce (Show a) from the context (Show (Baum a))
arising from a use of ‘show’ at test.hs:5:18-23
Possible fix:
add (Show a) to the context of the instance declaration
In the expression: show a
In the definition of ‘show’: show (Blatt a) = show a
In the instance declaration for ‘Show (Baum a)’
5.3 Record-Syntax für Haskell data-Deklarationen
Haskell bietet neben der normalen Definition von Datentypen auch die Möglichkeit eine spezielle Syntax zu verwenden, die insbesondere dann sinnvoll
ist, wenn ein Datenkonstruktor viele Argumente hat.
Wir betrachten zunächst den normal definierten Datentypen Student als
Beispiel:
> data Student = Student
D. Sabel, FP-PR, SoSe 2011
23
Stand: 4. April 2011
5 Datentypen und Typklassen in Haskell
>
>
>
>
>
Int
String
String
String
Int
------
Matrikelnummer
Vorname
Name
Studiengang
Fachsemester
Ohne die Kommentare ist nicht ersichtlich, was die einzelnen Komponenten
darstellen. Außerdem muss man zum Zugriff auf die Komponenten neue
Funktionen definieren. Beispielweise
> vorname :: Student -> String
> vorname (mno vorname name stdgang fsem) = vorname
Wenn nun Änderungen am Datentyp vorgenommen werden – zum Beispiel eine weitere Komponente für das Hochschulsemester wird hinzugefügt
– dann müssen alle Funktionen angepasst werden, die den Datentypen verwenden:
> data Student = Student
>
Int
-- Matrikelnummer
>
String -- Vorname
>
String -- Name
>
String -- Studiengang
>
Int
-- Fachsemester
>
Int
-- Hochschulsemester
>
> vorname :: Student -> String
> vorname (mno vorname name stdgang fsem hsem) = vorname
Um diese Nachteile zu vermeiden, bietet es sich an, die Record-Syntax
zu verwenden. Diese erlaubt zum einen die einzelnen Komponenten mit
Namen zu versehen:
> data Student = Student {
>
matrikelnummer
>
vorname
>
name
>
studiengang
>
fachsemester
>
}
Stand: 4. April 2011
24
::
::
::
::
::
Int,
String,
String,
String,
Int
D. Sabel, FP-PR, SoSe 2011
5.3 Record-Syntax für Haskell data-Deklarationen
Eine konkrete Instanz würde mit der normalen Syntax initialisiert mittels
Student 1234567 "Hans" "Mueller" "Informatik" 5
Für den Record-Typen ist dies genauso möglich, aber es gibt auch die Möglichkeit die Namen zu verwenden:
Student{matrikelnummer=1234567,
vorname="Hans",
name="Mueller",
studiengang="Informatik",
fachsemester=5}
Hierbei spielt die Reihenfolge der Einträge keine Rolle, z.B. ist
Student{fachsemester=5,
vorname="Hans",
matrikelnummer=1234567,
name="Mueller",
studiengang="Informatik"
}
genau dieselbe Instanz.
Zugriffsfunktionen für die Komponenten brauchen nicht zu definiert werden, diese sind sofort vorhanden und tragen den Namen der entsprechenden
Komponente. Z.B. liefert die Funktion matrikelnummer angewendet auf eine
Student-Instanz dessen Matrikelnummer. Wird der Datentyp jetzt wie oben
erweitert, so braucht man im Normalfall wesentlich weniger Änderungen
am bestehenden Code.
Die Schreibweise mit Feldnamen darf auch für das Pattern-Matching verwendet werden. Hierbei müssen nicht alle Felder spezifiziert werden. So ist
z.B. eine Funktion die testet, ob der Student einen Nachnamen beginnend
mit ’A’ hat implementierbar als
> nachnameMitA Student{nachname = ’A’:xs} = True
> nachnameMitA _ = False
Diese Definition ist äquivalent zur Definition
> nachnameMitA Student _ _ (’A’:xs) _ _ = True
> nachnameMitA _
= False
D. Sabel, FP-PR, SoSe 2011
25
Stand: 4. April 2011
6
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.
6.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.
D. Sabel, FP-PR, SoSe 2011
Stand: 4. April 2011
6.1 Module in Haskell
Ein Modul wird mittels
module Modulname(Exportliste)
 where

Modulimporte,

M odulrumpf
Datentypdefinitionen,


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-/Ausgabe-Aktion, 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 = ...
...
Im folgenden werden wir den Modulexport und -import anhand folgendes
Beispiels verdeutlichen:
Beispiel 6.1.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
D. Sabel, FP-PR, SoSe 2011
27
Stand: 4. April 2011
6 Modularisierung in Haskell
istNiederlage Niederlage = True
istNiederlage _
= False
6.1.1 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 6.1.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 6.1.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 6.1.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.
– 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.
Stand: 4. April 2011
28
D. Sabel, FP-PR, SoSe 2011
6.1 Module in Haskell
– 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 6.1.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.
6.1.2 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:
D. Sabel, FP-PR, SoSe 2011
29
Stand: 4. April 2011
6 Modularisierung in Haskell
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
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
Stand: 4. April 2011
30
D. Sabel, FP-PR, SoSe 2011
6.1 Module in Haskell
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
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
import-Anweisung sichtbar sind:
D. Sabel, FP-PR, SoSe 2011
31
Stand: 4. April 2011
6 Modularisierung in Haskell
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
6.1.3 Hierarchische Modulstruktur
Diese Erweiterung ist nicht durch den Haskell-Report festgelegt, wird jedoch
von GHC und Hugs unterstützt1 . 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 ausgeführt, so wird das Modul A/B/C.hs geladen, wobei A
und B Verzeichnisse sind.
Die „Haskell Hierarchical Libraries2 “ sind mithilfe der hierarchischen Modulstruktur aufgebaut, z.B. sind Funktionen, die auf Listen operieren, im
Modul Data.List definiert.
Damit der ghci die Module auch findet, muss das oberste Verzeichnis
der Modulstruktur für ihn auffindbar sein. Dafür gibt es beim ghci den
Parameter
-i<dir>
Search for imported modules in the directory <dir>.
1
An der Standardisierung der hierarchischen Modulstruktur wird gearbeitet, siehe
http://www.haskell.org/hierarchical-modules
2
siehe http://www.haskell.org/ghc/docs/latest/html/libraries
Stand: 4. April 2011
32
D. Sabel, FP-PR, SoSe 2011
6.1 Module in Haskell
Wenn wir z.B. gerade im Verzeichnis Verzeichnis1/Verzeichnis2/ sind
und wollen das Modul Verzeichnis1.Verzeichnis2.Datei mit Dateinamen
Datei.lhs laden, so sollten wir ghci wie folgt aufrufen:
ghci -i:../../ Datei.lhs
D. Sabel, FP-PR, SoSe 2011
33
Stand: 4. April 2011
7
I/O in Haskell
In einer rein funktionalen Programmiersprache mit verzögerter Auswertung
wie Haskell sind Seiteneffekte zunächst verboten. Fügt man Seiteneffekte
einfach hinzu (z.B. durch eine „Funktion“ getZahl), die beim Aufruf eine
Zahl vom Benutzer abfragt und anschließend mit dieser Zahl weiter auswertet, so erhält man einige unerwünschte Effekte der Sprache, die man im
Allgemeinen nicht haben möchte.
• Rein funktionale Programmiersprachen sind referentiell transparent, d.h.
eine Funktion angewendet auf gleiche Werte, ergibt stets denselben
Wert im Ergebnis. Die referentielle Transparenz wird durch eine Funktion wie getZahl verletzt, da getZahl je nach Ablauf unterschiedliche
Werte liefert.
• Ein weiteres Gegenargument gegen das Einführen von primitiven Ein/ Ausgabefunktionen besteht darin, dass übliche (schöne) mathematische Gleichheiten wie e + e = 2 ∗ e für alle Ausdrücke der Programmiersprache nicht mehr gelten. Setze getZahl für e ein, dann fragt e∗e
zwei verschiedene Werte vom Benutzer ab, während 2 ∗ e den Benutzer nur einmal fragt. Würde man also solche Operationen zulassen, so
könnte man beim Transformieren innerhalb eines Compilers übliche
mathematische Gesetze nur mit Vorsicht anwenden.
D. Sabel, FP-PR, SoSe 2011
Stand: 4. April 2011
• Durch die Einführung von direkten I/O-Aufrufen besteht die Gefahr, dass der Programmierer ein anderes Verhalten vermutet, als
sein Programm wirklich hat. Der Programmierer muss die verzögerte Auswertung von Haskell beachten. Betrachte den Ausdruck
length [getZahl, getZahl], wobei length die Länge einer Liste berechnet als
length [] = 0
length (_:xs) = 1 + length xs
Da die Auswertung von length die Listenelemente gar nicht anfasst,
würde obiger Aufruf, keine getZahl-Aufrufe ausführen.
• In reinen funktionalen Programmiersprachen wird oft auf die Festlegung einer genauen Auswertungsreihenfolge verzichtet, um Optimierungen und auch Parallelisierung von Programmen durchzuführen.
Z.B. könnte ein Compiler bei der Auswertung von e1 + e2 zunächst e2
und danach e1 auswerten. Werden in den beiden Ausdrücken direkte I/O-Aufrufe benutzt, spielt die Reihenfolge der Auswertung jedoch
eine Rolle, da sie die Reihenfolge der I/O-Aufrufe wider spiegelt.
Aus all den genannten Gründen, wurde in Haskell ein anderer Weg gewählt. I/O-Operationen werden mithilfe des so genannten monadischen I/O
programmiert. Hierbei werden I/O-Aufrufe vom funktionalen Teil gekapselt. Zu Programmierung steht der Datentyp IO a zu Verfügung. Ein Wert
vom Typ IO a stellt jedoch kein Ausführen von Ein- und Ausgabe dar, sondern eine I/O-Aktion, die erst beim Ausführen (außerhalb der funktionalen
Sprache) Ein-/Ausgaben durchführt und anschließend einen Wert vom Typ
a liefert.
D.h. man setzt innerhalb des Haskell-Programms I/O-Aktionen zusammen. Die große (durch main) definierte I/O-Aktion wird im Grunde dann
außerhalb von Haskell ausgeführt.
Eine anschauliche Vorstellung dabei ist die folgende. Eine I/O-Aktion ist
eine Funktion, die als Eingabe einen Zustand der Welt (des Rechners) erhält
und als Ausgabe den veränderten Zustand der Welt sowie ein Ergebnis
liefert. Als Haskell-Typ geschrieben:
type IO a = Welt -> (a,Welt)
Man kann dies auch durch folgende Grafik illustrieren:
D. Sabel, FP-PR, SoSe 2011
35
Stand: 4. April 2011
7 I/O in Haskell
Aus Sicht von Haskell sind Objekte vom Typ IO a bereits Werte, d.h. sie
können nicht weiter ausgewertet werden. Dies passt dazu, dass auch andere
Funktionen Werte in Haskell sind. Allerdings im Gegensatz zu „normalen“
Funktionen kann Haskell kein Argument vom Typ „Welt“ bereit stellen. Die
Ausführung der Funktion geschieht erst durch das Laufzeitsystem, welche
die Welt auf die durch main definierte I/O-Aktion anwendet.
Um nun I/O-Aktionen in Haskell zu Programmieren werden zwei Zutaten benötigt: Zum Einen benötigt man (primitive) Basisoperationen, zum
Anderen benötigt man Operatoren, um aus kleinen I/O-Aktionen größere
zu konstruieren.
7.1 Primitive I/O-Operationen
Wir gehen zunächst von zwei Basisoperationen aus, die Haskell primitiv
zur Verfügung stellt. Zum Lesen eines Zeichens vom Benutzer gibt es die
Funktion getChar:
getChar :: IO Char
In der Welt-Sichtweise ist getChar eine Funktion, die eine Welt erhält und
als Ergebnis eine veränderte Welt sowieso ein Zeichen liefert. Man kann dies
durch folgendes Bild illustrieren:
Analog dazu gibt es die primitive Funktion putChar, die als Eingabe ein
Zeichen (und eine Welt) erhält und nur die Welt im Ergebnis verändert.
Da alle I/O-Aktionen jedoch noch ein zusätzliches Ergebnis liefern müssen,
wird hier der 0-Tupel () verwendet.
putChar :: Char -> IO ()
Auch putChar lässt sich mit einem Bild illustrieren:
Stand: 4. April 2011
36
D. Sabel, FP-PR, SoSe 2011
7.1 Primitive I/O-Operationen
7.1.1 Komposition von I/O-Aktionen
Um aus den primitiven I/O-Aktionen größere Aktionen zu erstellen, werden
Kombinatoren benötigt, um I/O-Aktionen miteinander zu verknüpfen. Z.B.
könnte man zunächst mit getChar ein Zeichen lesen, welches anschließend
mit putChar ausgegeben werden soll. Im Bild dargestellt möchte man die
beiden Aktionen getChar und putChar wiefolgt sequentiell ausführen und
dabei die Ausgabe von getChar als Eingabe für putChar benutzen (dies gilt
sowohl für das Zeichen, aber auch für den Weltzustand):
Genau diese Verknüpfung leistet der Kombinator >>=, der „bind“ ausgesprochen wird. Der Typ des Kombinators ist:
(>>=) :: IO a -> (a -> IO b) -> IO b
D.h. er erhält eine IO-Aktion, die einen Wert vom Typ a liefert, und eine
Funktion die einen Wert vom Typ a verarbeiten kann, indem sie als Ergebnis
eine IO-Aktion vom Typ IO b erstellt.
Wir können nun die gewünschte IO-Aktion zum Lesen und Asgegeben
eines Zeichens mithilfe von >>= definieren:
echo :: IO ()
echo = getChar >>= putChar
Ein andere Variante stellt der >>-Operator (gesprochen: „then“) dar. Er
wird benutzt, um aus zwei I/O-Aktionen die Sequenz beider Aktionen zur
erstellen, wobei das Ergebnis der ersten Aktion nicht von der zweiten Aktion
benutzt wird (die Welt wird allerdings weitergereicht). Der Typ von >> ist:
(>>) :: IO a -> IO b -> IO b
Allerdings muss der Kombinator >> nicht primitiv zur Verfügung gestellt
werden, da er leicht mithilfe von >>= definiert werden kann:
(>>) :: IO a -> IO b -> IO b
(>>) akt1 akt2 = akt1 >>= \_ -> akt2
Mithilfe der beiden Operatoren kann man z.B. eine IO-Aktion definieren,
die ein gelesenes Zeichen zweimal ausgibt:
D. Sabel, FP-PR, SoSe 2011
37
Stand: 4. April 2011
7 I/O in Haskell
echoDup :: IO ()
echoDup = getChar >>= (\x -> putChar x >> putChar x)
Angenommen wir möchten eine IO-Aktion erstellen, die zwei Zeichen
liest und diese anschließend als Paar zurück gibt. Dann benötigen wir eine weitere Operation, um einen beliebigen Wert (in diesem Fall das Paar
von Zeichen) in eine IO-Aktion zu verpacken, die nichts anderes tut, als
das Paar zu liefern (die Welt wird einfach von der Eingabe zur Ausgabe
weitergereicht). Dies leistet die primitive Funktion return mit dem Typ
return :: a -> IO a
Als Bild kann man sich die return-Funktion wie folgt veranschaulichen:
Die gewünschte Operation, die zwei Zeichen liest und diese als Paar zurück liefert kann nun definiert werden:
getTwoChars :: IO (Char,Char)
getTwoChars = getChar >>= \x ->
getChar >>= \y ->
return (x,y)
Das Verwenden von >>= und dem nachgestellten „\x -> ...“-Ausdruck
kann man auch lesen als: Führe getChar durch, und binde das Ergebnis
an x usw. Deswegen gibt es als syntaktischen Zucker die do-Notation. Unter Verwendung der do-Notation erhält die getTwoChars-Funktion folgende
Definition:
getTwoChars :: IO (Char,Char)
getTwoChars = do {
x <- getChar;
y <- getChar;
return (x,y)}
Diese Schreibweise ähnelt nun sehr der imperativen Programmierung. Die
do-Notation ist jedoch nur syntaktischer Zucker, sie kann mit >>= wegkodiert
werden unter Verwendung der folgenden Regeln:
Stand: 4. April 2011
38
D. Sabel, FP-PR, SoSe 2011
7.1 Primitive I/O-Operationen
do { x<-e; s }
do { e; s }
do { e }
= e >>= \x -> do { s }
= e >> do { s }
= e
Als Anmerkung sei noch erwähnt, dass wir im Folgenden die geschweifte
Klammerung und die Semikolons nicht verwenden, da diese durch Einrückung der entsprechenden Kodezeilen vom Parser automatisch eingefügt
werden.
Als Fazit zur Implementierung von IO in Haskell kann man sich merken, dass neben den primitiven Operationen wie getChar und putChar, die
Kombinatoren >>= und return ausreichen, um genügend viele andere Operationen zu definieren.
Wir zeigen noch, wie man eine ganze Zeile Text einlesen kann, indem
man getChar wiederholt rekursiv aufruft:
getLine :: IO [Char]
getLine = do c <- getChar;
if c == ’\n’ then
return []
else
do
cs <- getLine
return (c:cs)
7.1.2 Einige gebräuchliche IO-Funktionen
Haskell bietet in der Prelude (und hauptsächlich in der Bibliothek
System.IO) jede Menge monadische Funktionen. Für die Ein- und Ausgabe
auf dem Bildschirm empfehlen sich die Funktionen
• getChar :: IO Char: Lesen eines Zeichens von der Standardeingabe
• getLine :: IO Char: Lesen einer Zeile von der Standardeingabe
• putChar :: Char -> IO (): Drucken eines Zeichens auf die Standardausgabe
• putStr :: String -> IO (): Drucken eines Strings auf die Standardausgabe
• putStrLn :: String -> IO (): Drucken eines Strings und anschließendem Zeilenende auf die Standardausgabe
D. Sabel, FP-PR, SoSe 2011
39
Stand: 4. April 2011
7 I/O in Haskell
• print :: (Show a) => a -> IO ()
und anschließendes Ausdrucken,
print a = putStrLn (show a))
Anzeigen
eines
Types
(print ist definiert als
Für die Dateibehandlung bietet sich im Wesentlichen an:
• readFile :: FilePath -> IO String: (Verzögertes) Lesen einer Datei
(FilePath ist ein Synonym für String und bezeichnet den Dateinamen
(einschließlich eines Pfades))
• writeFile :: FilePath -> String -> IO (): Schreiben eines Strings
in eine Datei
• appendFile :: FilePath -> String -> IO ()
Strings an eine bereits bestehende Datei
Anhängen
eines
Beachte, dass es mittels readFile und writeFile im Allgemeinen nicht ohne Weiteres möglich ist, zunächst eine Datei zu lesen und anschließend in
die gleiche Datei zu schreiben, da das Lesen den Zugriff auf die Datei solange blockiert, bis die gesamte Datei gelesen wurde. Man kann dies jedoch
durch explizites Öffnen und Schließen der Dateihandles umgehen (siehe
Dokumentation der Bibliothek System.IO).
7.2 Monaden
Bisher haben wir zwar erwähnt, dass Haskell monadisches IO verwendet. Wir
sind jedoch noch nicht darauf eingegangen, warum dieser Begriff verwendet
wird. Der Begriff Monade stammt aus dem Gebiet der Kategorientheorie (aus
der Mathematik). Eine Monade besteht aus einem Typkonstruktor M und
zwei Operationen:
(>>=) :: M a -> (a -> M b) -> M b
return :: a -> M a
wobei zusätzlich die folgenden drei Gesetze gelten müssen.
(1) return x >>= f
=f x
(2)
m >>= return
(3)
m1 >>= (\x -> m2 >>= (\y -> m3))
=
(m1 >>= (\x -> m2)) >>= (\y -> m3)
Stand: 4. April 2011
=m
40
D. Sabel, FP-PR, SoSe 2011
7.3 Verzögern innerhalb der IO-Monade
Da Monaden die Sequentialisierung erzwingen, ergibt sich, dass die Implementierung des IO in Haskell sequentialisierend ist, was i.A. gewünscht
ist.
Eine der wichtigsten Eigenschaften des monadischen IOs in Haskell ist,
dass es keinen Weg aus einer Monade heraus gibt. D.h. es gibt keine Funktion f:: IO a -> a, die aus einem in einer I/O-Aktion verpackten Wert
nur diesen Wert extrahiert. Dies erzwingt, dass man IO nur innerhalb der
Monade programmieren kann und i.A. rein funktionale Teile von der I/OProgrammierung trennen sollte.
Dies ist zumindest in der Theorie so. In der Praxis stimmt obige Behauptung nicht mehr, da alle Haskell-Compiler eine Möglichkeit bieten, die IOMonade zu „knacken“.
7.3 Verzögern innerhalb der IO-Monade
Wir betrachten ein Poblem beim monadischen Programmieren. Wir schauen uns die Implementierung des readFile an, welches den Inhalt einer
Datei ausliest. Hierfür werden intern Handles benutzt. Diese sind im Grunde „intelligente“ Zeiger auf Dateien. Für readFile wird zunächst ein solcher Handle erzeugt (mit openFile), anschließend der Inhalt gelesen (mit
leseHandleAus).
-- openFile :: FilePath -> IOMode -> IO Handle
-- hGetChar :: Handle -> IO Char
readFile
readFile
do
handle
inhalt
return
:: FilePath -> IO String
path =
<- openFile path ReadMode
<- leseHandleAus handle
inhalt
Es fehlt noch die Implementierung von leseHandleAus. Diese Funktion
soll alle Zeichen vom Handle lesen und anschließend diese als Liste zurückgegeben und den Handel noch schließen (mit hClose). Wir benutzen außerdem die vordefinierten Funktion hIsEOF :: Handle -> IO Bool, die testet
ob das Dateiende erreicht ist und hGetChar, die ein Zeichen vom Handle
liest.
Ein erster Versuch führt zur Implementierung:
D. Sabel, FP-PR, SoSe 2011
41
Stand: 4. April 2011
7 I/O in Haskell
leseHandleAus handle =
do
ende <- hIsEOF handle
if ende then
do
hClose handle
return []
else do
c <- hGetChar handle
cs <- leseHandleAus handle
return (c:cs)
Diese Implementierung funktioniert, ist allerdings sehr speicherlastig, da
das letzte return erst ausgeführt wird, nachdem auch der rekursive Aufruf
durchgeführt wurde. D.h. wir lesen die gesamte Datei aus, bevor wir irgendetwas zurückgegeben. Dies ist unabhängig davon, ob wir eigentlich nur das
erste Zeichen der Datei oder alle Zeichen benutzen wollen. Für eine verzögert auswertende Programmiersprache und zum eleganten Programmieren
ist dieses Verhalten nicht gewünscht. Deswegen benutzen wir die Funktion
unsafeInterleaveIO :: IO a -> IO a, die die strenge Sequentialisierung
der IO-Monade aufbricht, d.h. anstatt die IO-Aktion sofort durchzuführen
wird beim Aufruf innerhalb eines do-Blocks:
do
...
ergebnis <- unsafeInterleaveIO aktion
weitere_Aktionen
nicht die Aktion aktion durchgeführt (berechnet), sondern direkt mit den
weiteren Aktion weitergemacht. Die Aktion aktion wird erst dann ausgeführt, wenn der Wert der Variablen ergebnis benötigt wird.
Die
Implementierung
von
unsafeInterleaveIO
verwendet
unsafePerformIO:
unsafeInterleaveIO a = return (unsafePerformIO a)
Die monadische Aktion a vom Typ IO a wird mittels unsafePerformIO in
einen nicht-monadischen Wert vom Typ a konvertiert; mittels return wird
dieser Wert dann wieder in die IO-Monade verpackt.
Stand: 4. April 2011
42
D. Sabel, FP-PR, SoSe 2011
7.3 Verzögern innerhalb der IO-Monade
Die Funktion unsafePerformIO “knackt” also die IO-Monade. Im WeltModell kann man sich dies so vorstellen: Es wird irgendeine Welt benutzt
um die IO-Aktion durchzuführen, anschließend wird die neue Welt nicht
weiter gereicht, sondern sofort weg geworfen.
Die Implementierung von leseHandleAus ändern wir nun ab in:
leseHandleAus handle =
do
ende <- hIsEOF handle
if ende then
do
hClose handle
return []
else do
c <- hGetChar handle
cs <- unsafeInterleaveIO (leseHandleAus handle)
return (c:cs)
Nun liefert readFile schon Zeichen, bevor der komplette Inhalt der Datei
gelesen wurde. Beim Testen im Interpreter sieht man den Unterschied.
Mit der Version ohne unsafeInterleaveIO:
*Main> writeFile "LargeFile" (concat [show i | i <- [1..100000]])
*Main> readFile "LargeFile" >>= print . head
’1’
(7.09 secs, 263542820 bytes)
Mit Benutzung von unsafeInterleaveIO:
*Main> writeFile "LargeFile" (concat [show i | i <- [1..100000]])
*Main> readFile "LargeFile" >>= print . head
’1’
(0.00 secs, 0 bytes)
D. Sabel, FP-PR, SoSe 2011
43
Stand: 4. April 2011
7 I/O in Haskell
Beachte,
dass
beide
Funktionen
unsafeInterleaveIO
und
unsafePerformIO nicht vereinbar sind mit monadischen IO, da sie die
strenge Sequentialisierung aufbrechen. Ein Rechtfertigung die Funktionen
trotzdem einzusetzen besteht darin, dass man gut damit Bibliotheksfunktionen oder ähnliches definieren kann. Wichtig dabei ist, dass der Benutzer
der Funktion sich bewusst ist, dass deren Verwendung eigentlich verboten
ist und dass das Ein- / Ausgabeverhalten nicht mehr sequentiell ist. D.h.
sie sollte nur verwendet werden, wenn das verzögerte I/O das Ergebnis
nicht beeinträchtig
Stand: 4. April 2011
44
D. Sabel, FP-PR, SoSe 2011
8
Concurrent Haskell
Concurrent Haskell ist eine Erweiterung von Haskell um Konstrukte zur
nebenläufigen Programmierung. Diese Erweiterung wurde als notwendig
empfunden, um IO-lastige Real-World-Anwendungen, wie. z.B. Graphische
Benutzeroberflächen oder diverse Serverdienste (z.b. http-Server) in Haskell
zu implementieren.
Die neuen Konstrukte sind
• Nebenläufige Threads und Konstrukte zum Erzeugen solcher Threads
• Konstrukte zur Kommunikation zwischen Threads und zur Synchronisation von Threads.
Insgesamt wurden dafür folgende neue primitive Operationen zu Haskell
hinzugefügt, die innerhalb der IO-Monade verwendet werden dürfen.
Zur Erzeugung von nebenläufigen Threads existiert die Funktion
forkIO :: IO () -> IO ThreadId
Diese Funktion erwartet einen Ausdruck vom Typ IO () und führt diesen
in einem nebenläufigen Thread aus. Das Ergebnis ist eine eindeutige Identifikationsnummer für den erzeugten Thread. D.h. aus Sicht des Hauptthreads
liefert forkIO s sofort ein Ergebnis zurück. Im GHC ist zusätzlich eine Funktion killThread :: ThreadId -> IO () implementiert, die es erlaubt einen
nebenläufigen Thread anhand seiner ThreadId zu beenden. Ansonsten führt
Stand: 4. April 2011
D. Sabel, FP-PR, SoSe 2011
8 Concurrent Haskell
das Beenden des Hauptthreads auch immer zum Beenden aller nebenläufigen Threads.
Im GHC steht zusätzlich noch die sehr ähnliche Funktion
forkOS :: IO () -> IO ThreadId
zur Verfügung. Der Unterschied zwischen forkIO und forkOS besteht darin, wer den erzeugten nebenläufigen Thread verwaltet. Während mit forkIO
erzeugte Threads vom Haskell Runtime-System erzeugt und verwaltet werden, werden mit forkOS erzeugte Threads vom Betriebssystem verwaltet.
Zur Synchronisation und Kommunikation zwischen mehreren Threads
wurde ein neuer Datentyp MVar (mutable variable) eingeführt. Hierbei handelt es sich um Speicherplätze die im Gegensatz zum Datentyp IORef auch
zur Synchronisation verwendet werden können.
Für den Datentyp stehen drei Basisfunktionen zur Verfügung:
• newEmptyMVar :: IO (MVar a) erzeugt eine leere MVar, die Werte vom
Typ a speichern kann.
• takeMVar :: MVar a -> IO a liefert den Wert aus einer MVar und hinterlässt die Variable leer. Falls die entsprechende MVar leer ist, wird der
Thread, der die MVar lesen möchte, solange blockiert, bis die MVar gefüllt ist.
Wenn mehrere Threads ein takeMVar auf die gleiche (zunächst leere)
Variable durchführen, so wird nachdem die Variable einen Wert hat
nur ein Thread bedient. Die anderen Threads warten weiter. Die Reihenfolge hierbei ist first-in-first-out (FIFO), d.h. jener Thread der zuerst
das takeMVar durchführt, wird zuerst bedient.
• putMVar :: MVar a -> a -> IO () speichert den Wert des zweiten
Arguments in der übergebenen Variablen, wenn diese leer ist. Falls
die Variable bereits durch einen Wert belegt ist, wartet der Thread
solange, bis die Variable leer ist. Genau wie bei takeMVar wird bei
gleichzeitigem Zugriff von mehreren Threads auf die gleiche Variable
mittels putMVar der Zugriff in FIFO-Reihenfolge abgearbeitet.
MVars können leicht wie binäre (und sogar starke) Semaphoren benutzt
werden. das Anlegen einer Semaphore (mit 0 initialisiert) geschieht durch
das Anlegen einer leeren MVar. Die signal-Operation füllt die MVar mit irgendeinem Wert und die wait-Operation versucht die MVar zu leeren. Da
der Wert in der MVar nicht von Belang ist, verwenden wir das 0-Tupel ().
Insgesamt ergibt sich die Implementierung von Semaphoren als:
Stand: 4. April 2011
46
D. Sabel, FP-PR, SoSe 2011
type Semaphore = MVar ()
newSem
newSem
:: IO Semaphore
= newEmptyMVar
wait
wait sem
:: Semaphore -> IO ()
= takeMVar sem
signal
:: Semaphore -> IO ()
signal sem = putMVar sem ()
Beachte, dass die wait-Operation blockiert, wenn die MVar leer ist. Sobald eine signal-Operation die MVar füllt, wird die erste wartende waitOperation durchgeführt und der zugehörige Prozess entblockiert. Wird eine
verbotene signal-Operation auf einer vollen MVar durchgeführt, so ist das
Verhalten nicht undefiniert, sondern der signal ausführende Prozess wird
blockiert. Dieses Verhalten ist nicht durch Semaphoren festgelegt, deswegen
ist es eher uninteressant.
Wir betrachten nun, wie man mit MVars kritische Abschnitte direkt schützen kann.
Die im folgenden gezeigte echoS-Funktion liest eine Zeile von der Standardeingabe und druckt anschließend den gelesenen String auf der Standardausgabe aus. Die Funktion zweiEchosS erzeugt zwei nebenläufige Threads,
welche beide die echoS-Funktion ausführen. Würde man das Einlesen und
Ausgeben ungeschützt durchführen, so entsteht durch das Interleaving Chaos. Unsere Implementierung schützt jedoch den Zugriff auf die Standardeingabe und Standardausgabe durch Verwendung einer Semaphore (die
ja durch eine MVar implementiert ist). Nur derjenige Thread der ein erfolgreiches wait durchgeführt hat, darf passieren und auf die Standardeingabe
bzw. -ausgabe zugreifen. Nachdem er dies erledigt hat, gibt er den kritischen
Abschnitt wieder frei, indem er eine signal-Operation durchführt.
echoS sem i =
do
wait sem
putStr $ "Eingabe fuer Thread" ++ show i ++ ":"
line <- getLine
signal sem
D. Sabel, FP-PR, SoSe 2011
47
Stand: 4. April 2011
8 Concurrent Haskell
wait sem
putStrLn $ "Letzte Eingabe fuer Thread" ++ show i ++
signal sem
echoS sem i
zweiEchosS = do
sem <signal
forkIO
forkIO
block
":" ++ line
newSem
sem
(echoS sem 1)
(echoS sem 2)
Beachte, dass sich MVars in ihrem Blockierverhalten symmetrisch verhalten. Deshalb hätten wir die Implementierung von Semaphoren auch andersherum gestalten können: Ein Semaphore, die mit 0 belegt ist, wird durch eine gefüllte MVar dargestellt, die wait-Operation führt eine putMVar-Operation
durch und mit takeMVar wird signalisiert.
Die Haskell Bibliothek Control.Concurrent.MVar stellt noch weitere Operationen auf MVars zur Verfügung. Einige dieser Operationen werden im
folgenden erläutert.
• newMVar:: a -> MVar a
Erzeugt eine neue MVar, die mit dem als erstes Argument übergebenen
Ausdrück gefüllte wird.
• readMVar :: MVar a -> IO a
Liest den Wert einer MVar, entnimmt ihn aber nicht. Ist die MVar leer,
so blockiert der aufrufende Thread, bis die MVar gefüllt ist. Die Implementierung von readMVar ist eine Kombination von takeMVar und
putMVar
• swapMVar :: MVar a -> a -> IO a
Tauscht den Wert einer MVar aus, indem zunächst mit takeMVar der
alte Wert gelesen wird, und anschließend der neue Wert mit putMVar
in die MVar geschrieben wird. Die Rückgabe besteht im alten Wert der
MVar. Beachte, dass der Austausch nicht atomar geschieht, d.h. falls
nach dem herausnehmen des alten Werts ein zweiter Thread die MVar
beschreibt, kann ein “falscher” Wert in der MVar stehen.
Stand: 4. April 2011
48
D. Sabel, FP-PR, SoSe 2011
• tryTakeMVar :: MVar a -> IO (Maybe a)
tryTakeMVar versucht eine takeMVar-Operation durchzuführen. Ist die
MVar vorher gefüllt, so wird sie entleert und der Wert der MVar als Ergebnis (mit Just verpackt) zurück geliefert. Ist die MVar leer, so wird
nicht blockiert, sondern Nothing als Wert der I/O-Aktion zurück geliefert.
• tryPutMVar :: MVar a -> a -> IO Bool
Analog zu tryTakeMVar ist tryPutMVar eine nicht-blockierende Version
von putMVar. Das Ergebnis der Aktion ist ein Boolescher Wert: War das
Füllen der MVar erfolgreich, so ist der Wert True, anderfalls False
• isEmptyMVar :: MVar a -> IO Bool
Testet, ob eine MVar leer ist.
• modifyMVar_ :: MVar a -> (a -> IO a) -> IO ()
modifyMVar_ wendet eine Funktion auf den Wert eine MVar an. Die
Implementierung ist sicher gegenüber Exceptions: Falls ein Fehler bei
der Ausführung auftritt, so stellt modifyMVar_ sicher, dass der alte
Wert der MVar erhalten bleibt.
Auch für Concurrent Haskell selbst gibt es weitere Operationen (definiert
in Control.Concurrent). Wir zählen diese mit einigen bereits erwähnten
Operationen auf:
• forkIO :: IO () -> IO ThreadId
forkIO erzeugt einen nebenläufigen Thread. Dieser ist leichtgewichtig, er wird durch das Haskell-Laufzeitsystem verwaltet.
• forkOS :: IO () -> IO ThreadId
forkOS erzeugt einen “bound thread”, der durch das Betriebssystem
verwaltet wird.
• killThread :: ThreadId -> IO ()
killTread beendet den Thread mit der entsprechenden Identifikationsnummer. Wenn der Thread bereits vorher beendet ist, dann ist
killThread wirkungslos.
• yield :: IO ()
yield forciert einen Context-Switch: Der aktuelle Thread wird von
aktiv auf bereit gesetzt. Ein anderer Thread wird aktiv.
D. Sabel, FP-PR, SoSe 2011
49
Stand: 4. April 2011
8 Concurrent Haskell
• threadDelay :: Int -> IO ()
threadDelay verzögert den aufrufenden Thread um die gegebene Zahl
an Mikrosekunden.
8.1 Erzeuger / Verbraucher-Implementierung mit 1-Platz Puffer
Mithilfe von MVars kann ein Puffer mit einem Speicherplatz problemlos
implementiert werden. Der Puffer selbst wird durch eine MVar dargestell
type Buffer a = MVar a
Das Erzeugen eines Puffers entspricht dem Erzeugen einer MVar.
newBuffer = newEmptyMVar
Der Erzeuger schreibt Wert in den Puffer, und blockiert, solange der Puffer
voll ist. Dies entspricht genau der putMVar-Operation:
writeToPuffer = putMVar
Der Verbraucher entnimmt einen Wert aus dem Puffer und blockiert, falls
der Puffer leer ist. Dieses Verhalten wird genau durch takeMVar implementiert.
readFromBuffer
= takeMVar
8.2 Das Problem der Speisenden Philosophen
In diesem Abschnitt betrachten wir das Problem der speisenden Philosophen und Implementierung für fas Problem in Haskell. Es sitzen n Philosophen um einen runden Tisch und zwischen den Philosophen liegt genau
je eine Gabel. Zum Essen benötigt ein Philosoph beide Gabeln (seine linke
und seine rechte). Ein Philosoph denkt und isst abwechselnd. Wir stellen jede Gabel durch eine MVar () dar, die Philopsophen werden durch Threads
implementiert. Die naive Lösung kann wie folgt implementiert werden
philosoph i gabeln =
do
let n = length gabeln
-- Anzahl Gabeln
takeMVar $ gabeln!!i
-- nehme linke Gabel
putStrLn $ "Philosoph " ++ show i ++ " hat linke Gabel ..."
takeMVar $ gabeln!!(mod (i+1) n)
-- nehme rechte Gabel
Stand: 4. April 2011
50
D. Sabel, FP-PR, SoSe 2011
8.2 Das Problem der Speisenden Philosophen
putStrLn $ "Philosoph " ++ show i ++ " isst ..."
putMVar (gabeln!!i) ()
-- lege linke Gabel ab
putMVar (gabeln!!(mod (i+1) n)) ()
-- lege rechte Gabel ab
putStrLn $ "Philosoph " ++ show i ++ " denkt ..."
philosoph i gabeln
Das Hauptprogramm dazu erzeugt die Gabeln und die Philosophen:
philosophen n =
do
-- erzeuge Gabeln (n MVars):
gabeln <- sequence $ replicate n (newMVar ())
-- erzeuge Philosophen:
sequence_ [forkIO (philosoph i gabeln) | i <- [0..n-1]]
block
Beachte, dass sequence_ :: [IO a] -> IO () eine Liste von IO-Aktionen
sequentiell hintereinander ausführt, d.h. sequence_ [a1 ,. . .,an ] ist äquivalent zu a1 >> a2 >> . . . >> an .
Diese Lösung für das Philosophen-Problem kann in einem globalen Deadlock enden, wenn alle Philosophen die linke Gabel belegen, und damit alle
unendlich lange auf die rechte Gabel warten.
Eine Deadlock- und Starvationfreie Lösung besteht darin, den letzten
Philosophen die Gabeln in verkehrter Reihenfolge aufnehmen zu lassen,
da dann das Total-Order Theorem gilt. Die Haskell-Implementierung muss
hierfür wie folgt modifiziert werden:
philosophAsym i gabeln =
do
let n = length gabeln -- Anzahl Gabeln
if length gabeln == i+1 then -- letzter Philosoph
do takeMVar $ gabeln!!(mod (i+1) n) -- nehme rechte Gabel
putStrLn $ "Philosoph " ++ show i ++ " hat rechte Gabel ..."
takeMVar $ gabeln!!i -- nehme linke Gabel
putStrLn $ "Philosoph " ++ show i ++ " hat linke Gabel und isst"
else
do
takeMVar $ gabeln!!i -- nehme linke Gabel
putStrLn $ "Philosoph " ++ show i ++ " hat linke Gabel ..."
D. Sabel, FP-PR, SoSe 2011
51
Stand: 4. April 2011
8 Concurrent Haskell
takeMVar $ gabeln!!(mod (i+1) n) -- nehme rechte Gabel
putStrLn $ "Philosoph " ++ show i ++ " hat rechte Gabel und isst"
putMVar (gabeln!!i) () -- lege linke Gabel ab
putMVar (gabeln!!(mod (i+1) n)) () -- lege rechte Gabel ab
philosophAsym i gabeln
Das Hauptprogramm zum Erzeugen der Philosophenprozesse und der Gabeln bleibt dabei unverändert.
Wir hatten bereits gesehen, dass eine weitere Starvation- und Deadlockfreie Lösung des Philosophenproblems darin besteht, zu verhindern, alle
Philosophen gleichzeitig an die Gabeln zu lassen. Hierfür hatten wir eine generelle Semaphore raum benutzt, die mit dem Wert n − 1 initialisiert
wurde.
Bisher haben wir keine Implementierung von generellen Semaphoren
in Haskell gesehen. Es gibt zwar Kodierungen von generellen Semaphoren mithilfe von binären Semaphoren. Diese sind jedoch im Allgemeinen
kompliziert. Da wir jedoch MVars zur Verfügung haben, ist die Implementierung von generellen Semaphoren relativ einfach. In der Bibliothek
Control.Concurrent.QSem findet man die Implementierung von generellen
Semaphoren. Zunächst benutzen wir diese Semaphoren, um eine weitere
Lösung für das Philosophen-Problem zu erstellen. Im Anschluss werden
wir die Implementierung der generellen Semaphoren erörtern.
Wir benutzen eine QSem, die mit n − 1 initialisiert wird und dadurch verhindert, dass mehr als n − 1 Philosophen Zugriff auf die Gabeln erhalten.
Die Implementierung in Haskell für eine Philosophenprozess ist
philosophRaum i raum gabeln = do
let n = length gabeln -- Anzahl Gabeln
waitQSem raum -- generelle Semaphore
putStrLn $ "Philosoph " ++ show i ++ " im Raum"
takeMVar $ gabeln!!i -- nehme linke Gabel
putStrLn $ "Philosoph " ++ show i ++ " hat linke Gabel ..."
takeMVar $ gabeln!!(mod (i+1) n) -- nehme rechte Gabel
putStrLn $ "Philosoph " ++ show i ++ " hat rechte Gabel und isst"
putMVar (gabeln!!i) () -- lege linke Gabel ab
putMVar (gabeln!!(mod (i+1) n)) () -- lege rechte Gabel ab
signalQSem raum
putStrLn $ "Philosoph " ++ show i ++ " aus Raum raus"
putStrLn $ "Philosoph " ++ show i ++ " denkt ..."
Stand: 4. April 2011
52
D. Sabel, FP-PR, SoSe 2011
8.3 Futures
philosophRaum i raum gabeln
Das Hauptprogramm muss leicht abgeändert werden, da die generelle
Semaphore erzeugt werden muss:
philosophenRaum n =
do
gabeln <- sequence $ replicate n (newMVar ())
raum <- newQSem (n-1)
sequence [forkIO (philosophRaum i raum gabeln) | i <- [0..n-1]]
block
8.3 Futures
Futures sind Variablen deren Wert am Anfang unbekannt ist, aber in der
Zukunft (daher der Name) verfügbar wird, sobald die zur Future zugehörige
Berechnung beendet ist. In Haskell ist eigentlich jede Variable eine Future,
da verzögert ausgewertet wird. Wir betrachten in diesem Abschnitt jedoch
nebenläufige Futures, d.h. der Wert der Future wird durch eine nebenläufige
Berechnung ermittelt. Man kann zwischen expliziten und impliziten Futures
unterscheiden: Bei expliziten Futures muss der Wert einer Future explizit
angefordert werden. D.h. wenn ein Thread den Wert einer Future benötigt
muss er eine Operation auf der Future-Variablen ausführen, die solange
wartet, bis der Wert der Future berechnet wurde. Bei impliziten Futures
ist diese Operation unnötig, da die Auswertung automatisch den Wert der
Future bestimmt, wenn dieser benötigt wird.
Der Vorteil von (impliziten) Futures liegt darin, dass man manche Anwendungen relativ einfach programmieren kann, da die Synchronisation
automatisch geschieht.
Mithilfe von forkIO und MVars kann main explizite Futures wie folgt
implementieren:
type EFuture a = MVar a
efuture :: IO a -> IO (EFuture a)
efuture act =
do ack <- newEmptyMVar
forkIO (act >>= putMVar ack)
return ack
D. Sabel, FP-PR, SoSe 2011
53
Stand: 4. April 2011
8 Concurrent Haskell
force :: EFuture a -> IO a
force = readMVar
Eine explizite Future wird dabei durch eine MVar dargestellt. Das Erstellen einer expliziten Future wird durch die Funktion efuture durchgeführt.
Diese erwartet eine IO-Aktion (die den Wert der Future berechnet) und liefert (eine Referenz auf) die Future. Zunächst wird eine leere MVar erstellt,
anschließend wird eine nebenläufige Auswertung angestoßen: Diese führt
die übergebene IO-Aktion aus und schreibt das Ergebnis in die MVar Da
beim Aufruf von forkIO sofort weitergerechnet werden kann, wird sofort
die letzte Zeile ausgeführt: Die MVar wird zurück gegeben (diese stellt die
explizite Future dar).
Wenn der Wert der Future benötigt wird, muss der entsprechende Thread
die Funktion force ausführen. Diese versucht mittels readMVar den Wert
der Future zu lesen. Ist dieser noch nicht fertig berechnet, so wartet der
aufrufende Thread bis der Wert der Future verfügbar ist.
Ein Beispiel zur Verwendung von Futures ist die parallele Berechnung der
Summe der Knoten eines Baumes
data BTree a =
Leaf a
| Node a (BTree a) (BTree a)
treeSum (Leaf a)
= return a
treeSum (Node a l r) =
do
futl <- efuture (treeSum l)
futr <- efuture (treeSum r)
resl <- force futl
resr <- force futr
let result = (a + resl + resr)
in seq result (return result)
Für jeden inneren Knoten des Baumes werden für den linken und rechten
Teilbaum zwei Futures angelegt, die rekursiv deren Baumsummen berechnen. Anschließend wird auf den Wert beider Futures gewartet, zum Schluss
wird addiert. Hierbei wird seq verwendet, welches die verzögerte Auswertung in Haskell aushebelt, damit das Resultat wirklich ausgewertet wird,
Stand: 4. April 2011
54
D. Sabel, FP-PR, SoSe 2011
8.3 Futures
bevor es zurück gegeben wird. Ohne seq hätten wir parallel die unausgewertete Summe erzeugt.
Die Programmierung mit expliziten Futures ist nicht wirklich komfortabel, da der Wert der Futures explizit angefordert werden muss. Im Beispiel
wird dies auch noch sequentiell durchgeführt: Zuerst wird gewartet, dass
der Wert der linken Teilsumme danach der Wert der rechten Teilsumme
verfügbar ist. Es wäre eventuell effizienter zunächst resl + a als Zwischenergebnis zu berechnen und danach erst den Wert von futr anzufordern.
Es wäre besser, wenn wir auf das force-Kommando verzichten könnten
und direkt schreiben könnten: result = (a + futl + futr). Dies leisten
die expliziten Futures allerdings nicht.
Mithilfe von unsafeInterleaveIO ist es jedoch möglich implizite Futures
zu implementieren:
future :: IO a -> IO a
future code = do ack <-newEmptyMVar
thread <- forkIO (code >>= putMVar ack)
unsafeInterleaveIO (do result <- takeMVar ack
killThread thread
return result)
Mit future wird eine implizite Future erzeugt: Zunächst wird eine leere
MVar erzeugt, anschließend wird (wie vorher) nebenläufig die übergebene
IO-Aktion ausgeführt und das Resultat in die MVar geschrieben. Der letzte
Schritt besteht darin, das Resultat aus der MVar zu lesen, den nebenläufigen
Thread zu beenden und das Ergebnis zurück zu liefern. Würden wir dies
ohne den umgebenen unsafeInterleaveIO-Aufruf durchführen, würde ein
future e Aufruf solange blockieren, bis der Wert der Future ermittelt ist.
Durch unsafeInterleave wird jedoch sofort ein Ergebnis geliefert (da die
Sequentialisierung der IO-Monade aufgebrochen wird).
Beachte auch, dass es wenig sinnvoll wäre unsafeInterleaveIO um den
gesamten Code zu schreiben, da dann der nebenläufige Thread nicht sofort
gestartet würde.
Die parallele Baumsumme kann nun wie folgt berechnet werden
treeSum (Leaf a)
= return a
treeSum (Node a l r) = do
futl <- future (treeSum l)
futr <- future (treeSum r)
D. Sabel, FP-PR, SoSe 2011
55
Stand: 4. April 2011
8 Concurrent Haskell
let result = (a + futl + futr)
in seq result (return result)
Wir sehen, dass die Implementierung nun einfach wurde, die Werte der
Futures werden implizit durch die Auswertung angefordert (da (+) beide
Argumentwerte benötigt).
Stand: 4. April 2011
56
D. Sabel, FP-PR, SoSe 2011
Herunterladen