Anleitung - Professur für Künstliche Intelligenz und

Werbung
Praktikum:
Funktionale Programmierung [FP-PR]
Anleitung zum Praktikum
Sommersemester 2009
Stand vom: 15. Juni 2009
Professur für Künstliche Intelligenz und Softwaretechnologie
Institut für Informatik, Goethe-Universität Frankfurt am Main
Inhaltsverzeichnis
1 Allgemeines
1.1
1.2
1.3
1.4
3
Organisation . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
3
1.1.1
Computerraum . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
3
1.1.2
Regelmäßiges Treffen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
3
1.1.3
Scheinvergabe (Diplomstudierende) / Modulprüfung (Masterstudierende)
3
Haskell . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
4
1.2.1
Haskell . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
4
Quellcode . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
5
1.3.1
Dokumentation . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
6
Tests . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
7
2 Kurzübersicht über das Projekt
8
2.1
Das Softwareprojekt . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
8
2.2
Die Sprache LFP+C . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
8
2.3
Aufbau eines Compilers . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
11
2.4
Compiler, Interpreter, Virtuelle Maschine . . . . . . . . . . . . . . . . . . . . . .
12
2.5
Zeitplan . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
13
2.5.1
13
Präsentation der Ergebnisse . . . . . . . . . . . . . . . . . . . . . . . . . .
3 Der Compiler
15
3.1
Lexikalische Analyse . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
16
3.2
Syntaktische Analyse . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
18
3.2.1
. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
19
3.3
Semantische Analyse . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
24
3.4
Transformation in CoreLFPCR . . . . . . . . . . . . . . . . . . . . . . . . . . . .
25
Übersetzung
1
4 Verzögert auswertende Abstrakte Maschinen
4.1
4.2
4.3
4.4
4.5
Die Abstrakte Maschine Mark 1 zur Ausführung deterministischer CoreLFPCRProgramme . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
27
4.1.1
Implementierung . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
29
Verbesserungen von Mark 1 – Mark 2 . . . . . . . . . . . . . . . . . . . . . . . .
34
4.2.1
Implementierung . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
36
Nebenläufigkeit – Concurrent Mark 2 . . . . . . . . . . . . . . . . . . . . . . . . .
38
4.3.1
Nebenläufigkeit und Prozessbäume . . . . . . . . . . . . . . . . . . . . . .
39
4.3.2
Faire Thread-Auswahl . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
42
4.3.3
Sicheres Beenden von Threads . . . . . . . . . . . . . . . . . . . . . . . .
45
Die Virtuelle Maschine, der Compiler und der Interpreter . . . . . . . . . . . . .
49
4.4.1
Codegenerierung und Codeloader der VM . . . . . . . . . . . . . . . . . .
49
4.4.2
lfpcc – Der Compiler für LFP+C . . . . . . . . . . . . . . . . . . . . . . .
50
4.4.3
lfpcvm – Die Virtuelle Maschine . . . . . . . . . . . . . . . . . . . . . . .
50
4.4.4
lfpci – Ein Interpreter für LFP+C . . . . . . . . . . . . . . . . . . . . . .
50
Automatische Speicherverwaltung . . . . . . . . . . . . . . . . . . . . . . . . . . .
50
5 Hinweise zu einzelnen Themen
5.1
27
52
Concurrent Versions System . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
52
5.1.1
Zugriff per ssh . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
52
5.1.2
Arbeitskopie vom Server holen . . . . . . . . . . . . . . . . . . . . . . . .
52
5.1.3
Arbeitskopie lokal aktualisieren . . . . . . . . . . . . . . . . . . . . . . . .
53
5.1.4
Dateien einchecken . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
53
5.1.5
Hinzufügen von Dateien und Verzeichnissen . . . . . . . . . . . . . . . . .
53
5.1.6
Keyword-Substitution und Binäre Dateien . . . . . . . . . . . . . . . . . .
53
5.1.7
Graphische Oberflächen für CVS . . . . . . . . . . . . . . . . . . . . . . .
54
5.2
Record-Syntax für Haskell data-Deklarationen . . . . . . . . . . . . . . . . . . .
54
5.3
Debugging . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
56
5.4
Modularisierung in Haskell
. . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
56
Module in Haskell . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
57
Parser und Parsergeneratoren . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
61
5.5.1
Parser und Syntaxanalyse . . . . . . . . . . . . . . . . . . . . . . . . . . .
61
5.5.2
Parsergeneratoren . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
63
5.5.3
Happy . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
64
Haddock – A Haskell Documentation Tool . . . . . . . . . . . . . . . . . . . . . .
68
5.6.1
68
5.4.1
5.5
5.6
Dokumentation einer Funktionsdefinition . . . . . . . . . . . . . . . . . .
2
Kapitel 1
Allgemeines
1.1
1.1.1
Organisation
Computerraum
Der Raum 026 (im Keller des Informatikgebäudes) ist Montags von 14-18h reserviert, d.h. wer
möchte kann dann an diesen Rechnern arbeiten. Dieser Termin ist kein Pflichttermin, sondern
stellt nur ein Angebot dar.
1.1.2
Regelmäßiges Treffen
Montags, um 14 s.t. findet in Seminarraum 9 ein regelmäßiges Treffen aller Praktikumsteilnehmer statt. Hier sollen Fragen und Probleme diskutiert werden, die Anwesenheit ist somit i.A.
erforderlich.
1.1.3
Scheinvergabe (Diplomstudierende) / Modulprüfung (Masterstudierende)
Der Leistungsschein für Diplomstudierende wird für die korrekte und vollständige Bearbeitung der Aufgaben vergeben. Dee Modulprüfung Masterstudierende wird für die korrekte und
vollständige Bearbeitung der Aufgaben vergeben. Die Benotung findet aufgrund der abgegebenen Programme und Dokumentation statt. Hierfür sollte zusätzlich ein selbst verfasstes Protokoll
(maximal 2 Seiten) über die selbst erbrachten Leistungen abgegeben werden.
Es sollen je 2–4 Praktikanten für das Praktikum eine Gruppe bilden.
Zur Erfüllung der Aufgaben gehört die Abgabe und Präsentation eines hier auf den Rechnern der
RBI laufenden, kommentierten Programms, sowie eine schriftliche Ausarbeitung, die folgendes
enthält:
• eine kurze Erläuterung der Grundlagen, die zur Lösung der Aufgabe notwendig waren.
• eine kurze Beschreibung der Lösung selbst.
3
• ein Protokoll, das den Prozess der Problemlösung dokumentiert. Hierbei sollte insbesondere
erfasst werden, wieviel Zeit für die einzelnen Tätigkeiten benötigt wurden, und wie die
Arbeiten innerhalb der Gruppe aufgeteilt wurden.
• die Dokumentation der durchgeführten Tests.
In Abschnitt 2.5 ist ein Zeitplan gegeben, dieser sollte eingehalten werden. Es gibt drei Zeitpunkte zu denen (Zwischen-)ergebnisse präsentiert werden sollen. Gleichzeitig soll zu diesen
Zeitpunkten der aktuelle Stand der schriftlichen Ausarbeitung abgegeben werden!
Außerdem sei nochmal erwähnt, dass die Beteiligung bzw. das Stellen von Fragen bei den Besprechungen ausdrücklich erwünscht ist; davon hat nämlich sowohl der Frager, der Betreuer als
auch die Kommilitonen etwas.
1.2
Haskell
Die Implementierung der Programme soll in Haskell erfolgen. Es wird vorausgesetzt, dass die
Teilnehmer bereits über Kenntnisse in Haskell oder anderen Funktionalen Programmiersprachen
verfügen.
1.2.1
Haskell
Haskell1 ist eine der zur Zeit wohl bedeutendsten, nicht-strikten, funktionalen Programmiersprachen. Wer sich eingehender in Haskell vertiefen möchte sei auf den Haskell-Report [5] verwiesen,
in dem die Sprache definiert wird.
Zur Einarbeitung in Haskell sind das deutsche Buch [2], die Bücher [17], [1], [9] sowie [3] und
auch die Skripte [13] und [12] empfehlenswert.
Für Haskell gibt es mittlerweile eine recht große Anzahl von Standardbibliotheken,
die hierarchisch organisiert sind. Die Dokumentation der Bibliotheken ist online unter
http://www.haskell.org/ghc/docs/latest/html/libraries/index.html zu finden.
Zum Haskell-Programmieren stehen uns Hugs und GHC zur Verfügung2 .
GHC
Der Glasgow Haskell Compiler3 hält sich an den Haskell-Report. Im GHC gibt es Erweiterungen,
die z.B. die Typklassen betreffen. Der GHC ist fast vollständig in Haskell geschrieben und erzeugt C-Code als Zwischencode, der dann von einem auf dem System verfügbaren C-Compiler
in ein ausführbares Programm übersetzt wird. Diese Tatsache macht den GHC äußerst portierbar. Für einige Plattformen gibt es zusätzlich einen Code-Erzeuger, dessen Verwendung die
Übersetzungszeit reduziert, aber nicht zu so gut optimiertem Code führt wie die Verwendung
des GNU C Übersetzers. GHC bietet zusätzlich noch den GHCi, der eine interaktive Version des
GHC darstellt.
1
Die offizielle Homepage zu Haskell ist http://haskell.org.
Ein vollständige Liste aller Haskell-Implementierungen ist unter http://www.haskell.org/haskellwiki/Implementations
zu finden.
3
Die Homepage des GHC ist http://haskell.org/ghc.
2
4
Hugs
Hugs4 steht für Haskell Users Gofer System und ist ein Interpreter der Sprache Haskell 98. Es
gibt ihn für verschiedene Rechner und Betriebssysteme: darunter Mac, Windows, Unix, Linux.
Hugs kann zum schnellen Testen von Implementierungen benutzt werden, verfügt jedoch nicht
über den für das Praktikum notwendigen vollen Funktionsumfang, da z.B. kein Preprocessing
mittels Hugs durchgeführt werden kann.
Cabal, Hackage
Neben den Standardbibliotheken gibt es unter http://hackage.haskell.org eine große Sammlung
weiterer Pakete, die in einem standardisierten Format vorliegen (so genannte Cabal-Packages).
Für einzelne Aufgaben bieten sich für das Praktikum die Pakete
• haddock http://hackage.haskell.org/cgi-bin/hackage-scripts/package/haddock
• readline http://hackage.haskell.org/cgi-bin/hackage-scripts/package/readline
• QuickCheck http://hackage.haskell.org/cgi-bin/hackage-scripts/package/QuickCheck
an. Unter http://www.haskell.org/haskellwiki/Cabal/How to install a Cabal package kann man
nachlesen, wie diese installiert werden.
1.3
Quellcode
Der Quellcode sollte wartbar sein, und dementsprechend aufgebaut, dokumentiert und kommentiert werden.
Die vorgegebenen Dateien sind mithilfe der hierarchischen Modulstruktur (siehe Abschnitt 5.4)
strukturiert. Sämtlicher neuer Quellcode sollte in diese Struktur eingefügt werden. Zudem sollte
durch geeignete Import- und Export-Listen in den Moduldeklarationen eine geeignete Kapselung
erfolgen.
Die bereits vorgegebene Struktur sieht wie folgt aus:
src/
|-- LFPC
|
|-- AbsM
|
|
|-- CodeGen.lhs
|
|
|-- ConcurrentMark2.lhs
|
|
|-- Environment.lhs
|
|
|-- Heap.lhs
|
|
|-- Mark1.lhs
|
|
|-- Mark2.lhs
|
|
|-- Stack
|
|
|
‘-- StackElem.lhs
|
|
‘-- Stack.lhs
|
|-- Compiler
4
Die Homepage von Hugs ist http://haskell.org/hugs/.
5
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|-‘--
|
|-|
|
|
|-|
|-|
|
|
|
|
|-|-|
|-|
|
‘--
‘-- Main.lhs
CoreL
|-- CoreExpression.lhs
|-- MachineExp.lhs
‘-- TransCode.lhs
Interpreter
|-- Main.lhs
Parse
|-- InternalOp.hs
|-- InternalOp.lfp
|-- Lex.lhs
|-- Parser.hs
‘-- Parser.ly
Run.lhs
SemAna
‘-- RenameBV.lhs
Util
|-- Declarations.lhs
‘-- PTree.lhs
VM
‘-- Main.lhs
doc
genDoc.sh
Damit der ghci das Modul 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>.
Wenn wir z.B. gerade im Verzeichnis LFPC/Parse/ sind und wollen das Modul mit Dateinamen
Lex.lhs laden, so sollten wir ghci wie folgt aufrufen:
ghci -i:../../ Lex.lhs
Nahezu sämtliche vorgegebenen Quellcode-Dateien sind im Literate Haskell“-Stil verfasst. Hier”
bei werden alle Text-Zeilen als Kommentar interpretiert, es sei denn sie beginnen mit > ,
zusätzlich muss sich zwischen Kommentar und Codezeilen immer eine Leerzeile befinden, ansonsten bekommen wir eine Fehlermeldung:
.... line 8: unlit: Program line next to comment
phase ‘Literate pre-processor’ failed (exitcode = 1)
Der Quellcode wird mittels CVS5 verwaltet werden. (siehe Abschnitt 5.1).
1.3.1
Dokumentation
Die erstellten Programme sollten ausführlich kommentiert werden, so dass ein Leser“ des Pro”
gramms dieses nachvollziehen kann. Zusätzlich soll für die Module eine HTML-Dokumentation
mit Hilfe des Tools Haddock6 erstellt werden. Ein kurze Anleitung zu Haddock ist im Abschnitt
5
6
Concurrent Versions System, http://www.nongnu.org/cvs/
http://haskell.org/haddock
6
5.6 zu finden. Es sei jedoch angemerkt, dass eine reine Haddock-Dokumentation nicht ausreicht,
da mit Haddock nur die exportierten Funktionen und Datentypen dokumentiert werden und
zudem eher deren Verwendung und nicht deren Implementierung erklärt wird.
Das Erstellen der Dokumentation mit Haddock erfordert u.a. einen Aufruf sämtlicher Module
innerhalb eines Kommandos. Um dies zu automatisieren, steht das Shell-Skript genDoc.sh bzw.
genDoc.bat (im Verzeichnis src) zur Verfügung, welches im Abschnitt 5.6 kurz erläutert wird.
1.4
Tests
Sämtliche implementierten Funktionen, Datenstrukturen und Module sollten getestet werden.
Teilweise sind Testdaten bzw. Testaufrufe vorgegeben. Diese müssen durchgeführt und im Idealfall auch bestanden werden. Es ist aber auch notwendig eigene Tests mit sinnvoll überlegten
Testaufrufen durchzuführen. Sämtliche Tests sind zu dokumentieren und derart zu gestalten, dass sie leicht erneut durchgeführt, d.h. reproduziert, werden können. Zum Testen bietet sich eventuell auch die QuickCheck-Bibliothek (http://hackage.haskell.org/cgi-bin/hackagescripts/package/QuickCheck) an.
7
Kapitel 2
Kurzübersicht über das Projekt
2.1
Das Softwareprojekt
Innerhalb des Praktikums soll ein Compiler für eine funktionale Programmiersprache namens
LFP+C entwickelt werden. LFP+C wertet verzögert aus und verfügt zusätzlich über Programmkonstrukte amb und por zur nebenläufigen Auswertung.
Im Praktikum sollen die wesentlichen Phasen des Kompilierens für LFP+C implementiert werden,
und im wichtigerer Teil werden verschiedene aufeinander aufbauende abstrakte Maschinen bzw.
eine virtuellen Maschine implementiert. Im nächsten Abschnitt wird die Programmiersprache
LFP+C vorgestellt. Anschließend wird ein Überblick über das Projekt gegeben.
2.2
Die Sprache LFP+C
Abbildung 2.1 zeigt die vollgeklammerte Syntax der Sprache LFP+C .
Die Sprache LFP+C verfügt über Variablen, Abstraktionen, Applikation, rekursive letrecAusdrücke, seq-Ausdrücke zur sequentiellen Auswertung, Zahlen mit den Operationen Addition
(+), Subtraktion (-), Multiplikation (*) und einem Nulltest (null?), Boolesche Werte True und
False, Paare (geschrieben als (a,b)), Listenkonstruktoren (:) und ([]), case-Ausdrücke um
Listen, Paare oder Boolesche Werte zu zerlegen. Zusätzlich zur Grammatik muss gelten, dass
die Variablen V1 , . . . , Vn der Bindungen von letrec-Ausdrücken paarweise verschieden sind,
gleiches gilt für die Variablen V1 und V2 in case-Alternativen. Zudem darf es keine doppelten Alternativen, d.h. Alternativen für den selben Konstruktor innerhalb der case-Alternativen
geben.
Als besondere Spezialität gibt es die beiden nichtdeterministischen Operatoren amb und por.
Während das parallele Oder“ por schwach nichtdetermintistisch ist, wird durch amb starker
”
Nichtdeterminismus eingeführt. Schwacher Nichtdeterminismus meint hierbei, dass der Wert eines Programms eindeutig ist, obwohl es verschiedene Möglichkeiten (Auswertungen) gibt diesen
Wert zu erreichen. Hingegen bedeutet starker Nichtdeterminismus, dass je nach gewähltem Auswertungspfad das Programm verschiedene Werte als Ergebnis liefern kann. Ein Überblick über
verschiedene nichtdeterministische Operatoren und Arten von Nichtdeterminismus in Funktionalen Programmiersprachen ist z.B. in [16] zu finden.
8
Expr
::=
|
|
|
|
|
|
|
|
|
|
|
Var | Int | True | False | [] | (Expr 1 :Expr 2 ) | (Expr 1 ,Expr 2 )
(\Var -> Expr )
(Expr 1 Expr 2 )
(case Expr of {Alt 1 . . . Alt n })
(letrec Var 1 = Expr 1 , . . . , Var n = Expr n in Expr n+1 )
(seq Expr 1 Expr 2 )
(amb Expr 1 Expr 2 )
(por Expr 1 Expr 2 )
(Expr 1 + Expr 2 )
(Expr 1 - Expr 2 )
(Expr 1 * Expr 2 )
(null? Expr )
Alt
::= Pat -> Expr
Pat
::= True | False | [] | (Var 1 :Var 2 ) | (Var 1 ,Var 2 )
Abbildung 2.1: Vollgeklammerte Syntax der Sprache LFP+C
Bemerkungen zur Semantik einzelner Konstrukte
Die Semantik von seq lässt sich ungefähr durch die folgenden Gleichungen beschreiben, wobei
⊥ für einen Ausdruck steht, dessen Auswertung nicht terminiert.
seq s t
seq s t
=
=
t, falls die Auswertung von s terminiert
⊥, in allen anderen Fällen.
D.h. seq sequentialisiert die Auswertung und bedeutet informal: “Werte erst s aus, dann t”.
Die Semantik von por lässt sich ungefähr durch die folgenden Gleichungen beschreiben.
por s t
por s t
por s t
=
=
=
True, falls s oder t zu True auswertet
False, falls s und t zu False auswerten
⊥, in allen anderen Fällen.
Eine Anwendung des por Operators ist die Beschreibung und semantische Modellierung von
sequentiellen Schaltungen mit asynchronen Anteilen (direkten Rückkopplungen) innherhalb von
funktionalen Programmiersprachen. Eine Untersuchung dazu ist z.B. in [14] zu finden.
Die Semantik von amb (welches auf [7] zurück geht und von ambigious“ abgeleitet ist) kann
”
inetwa durch folgende Gleichungen spezifiziert werden:
amb s t
amb s t
amb s t
=
=
=
t,
s,
s oder t,
falls die Auswertung von s nicht terminiert.
falls die Auswertung von t nicht terminiert.
falls die Auswertungen von s und t terminieren.
9
Untersuchungen von call-by-need Lambda-Kalkülen, die über einen amb-Operator verfügen sind
in [4, 8, 11, 10] zu finden.
Die beiden nichtdeterministischen Operatoren können durch nebenläufige Auswertung implementiert werden, für einen Ausdruck (por s t) bzw. (amb s t) wird jeweils eine nebenläufige
Auswertung für s als auch für t gestartet. Im Falle des amb wird sobald einer der beiden Threads
einen Wert liefert, dieser als Wert des Gesamtausdrucks übernommen. Im Falle von por wird
True als Wert übernommen, sobald einer der beiden Threads True liefert, und False falls beide
nebenläufigen Auswertungen mit dem Wert False beendet wurden.
Beispiele
Wir geben noch einige Beispiel-Ausdrücke an, um zu verdeutlichen, wie in LFP+C programmiert
wird.
Beispiel 1. Eine Funktion, die eine Zahl verdoppelt, kann definiert werden als
letrec double = \x -> x+x in double
Die Auswertung von (letrec double = \x -> x+x in double) 20 ergibt 40.
Beispiel 2. Ein Ausdruck, der die Liste [1, 2, 3] von Zahlen, umdreht kann in LFP+C programmiert werden als
letrec reverse = \xs -> case xs of {
[]
-> [],
(y:ys) -> append
},
append = \xs -> \ys -> case xs of
[]
->
(u:us) ->
}
in reverse 1:2:3:[]
(reverse ys) (y:[])
{
ys,
u:(append us ys)
Die Auswertung ergibt dann (3:(2:(1:[])))
Beispiel 3. Eine optimierte Variante mit Akkumulator und seq zur Berechnung der Fakultät:
letrec
fakakku = \x -> \akk ->
case null? x of {
True -> akk,
False -> letrec zres = x*akk
in seq zres (fakakku (x - 1) zres)
}
in \x -> fakakku x 1
Das seq sorgt dafür, dass der Akkumulator nicht linear wächst, sondern vor jedem Rekursionsschritt ausgewertet wird. (Wobei dies auch von der Implementierung von * abhängt, da seq nur
bis zur schwachen Kopfnormalform auswertet.)
10
Beispiel 4. Die folgende asynchrone Schaltung
kann in LFP+C beschrieben werden durch:
\a ->
letrec
x =
y =
z =
not
in x
por x y,
por z y,
por (not a) a,
= \b -> case b of {True -> False, False -> True}
Angewendet auf True oder False liefert die Funktion stets True.
Beispiel 5. Ein Ausdruck der potentiell zu jeder natürlichen Zahl auswerten kann ist
letrec nat = \x -> amb (nat (x+1)) x in nat 1
Die Auswertung des amb kann entweder das aktuelle x wählen oder eins dazu addieren und dann
weitermachen.
2.3
Aufbau eines Compilers
Wir beschreiben grob die Phasen eines Compilers
Lexikalische Analyse, Scanning Hierbei wird der Quelltext in einen Strom von Token transformiert, Leerzeichen, Umbrüche, Kommentare usw. werden dabei entfernt. Ein Token ist
eine syntaktische Einheit, wie z.B. Schlüsselwörter (letrec, case, in,. . . ) oder Zahlen.
Der Lexer sollte dabei den Eingabestring nur einmal durchlaufen und somit lineare Zeit
(in der Anzahl der Zeichen) verbrauchen. Die Ausgabe des Lexers wird an den Parser
übergeben.
Syntaktische Analyse, Parsing Hier wird geprüft, ob der Tokenstrom von der kontextfreien
Grammatik hergeleitet wird, d.h. das Programm syntaktisch korrekt ist. Es gibt hierfür
verschiedene Parse-Methoden, wir werden einen sog. Shift-Reduce-Parser automatisch mithilfe eines Parsergenerators erzeugen. Die Ausgabe des Parsers ist i.A. ein Syntaxbaum,
wir werden den Syntaxbaum mittels eines Haskell-Datentyps darstellen, wobei er zugleich
ein wenig modifiziert wird.
Semantische Analyse Hierbei wird das Programm auf semantische Fehler überprüft. Z.B.
wird bei manchen imperativen Programmen geprüft, ob alle benutzten Variablen auch
deklariert sind, oder auch ein Typcheck durchgeführt. Wir werden in dieser Phase prüfen,
ob unser Programm geschlossen ist, d.h. keine freien Variablen enthält.
11
Zwischencodeerzeugung Hier wird Code für eine abstrakte Maschine erzeugt.
Codeoptimierung Der erzeugte Zwischencode wir optimiert.
Codeerzeugung Erzeugung eines Programms für eine reale Maschine. Wir werden jedoch keinen Code für eine reale Maschine, sondern ASCII-Code“ für eine virtuelle Maschine er”
zeugen.
2.4
Compiler, Interpreter, Virtuelle Maschine
Abbildung 2.2 gibt einen ungefähren Überblick über die einzelnen Teilschritte unseres Compilers
(LFPCC), der Virtuellen Maschine (LFPCVM) und des Interpreters (LFPCI), welche wir im
Praktikum implementieren werden.
Abbildung 2.2: Projektübersicht
Neben den einzelnen Phasen des Compilers wird im Praktikum, wie bei der Codeerzeugung schon
erwähnt, eine virtuelle Maschine erstellt. Wir werden jedoch vorher verschiedene Varianten der
abstrakten Maschine von Sestoft [15] implementieren und diese um nebenläufige Auswertung erweitern, was notwendig ist, um die beiden nichtdeterministischen Operatoren korrekt auswerten
zu können. Schließlich wird die virtuelle Maschine daraus relativ einfach abgeleitet. Der rechte
Teil von Abbildung 2.2 zeigt die einzelnen Maschinen.
Hierbei ist zu bemerken, dass die Maschine Mark 2 nur eine Verbesserung der Maschine Mark 1
ist. Die Maschinen Mark 1 und Mark 2 können nur deterministische Programme, d.h. Programme, die die Operatoren por und amb nicht enthalten, ausführen. Die Maschine Concurrent Mark 2
ist schließlich eine Erweiterung der Maschine Mark 2 um nebenläufige Auswertung. Die bisher
12
genannten Maschinen erhalten Datenstrukturen (Bäume) als Eingabe, und sind daher nicht direkt als Virtuelle Maschinen brauchbar (jedoch als Interpreter, wie die Abbildung schon zeigt).
Deswegen wird ein Programm in der letzten Phase des Compilers in ASCII-Code konvertiert,
der dann in eine Datei geschrieben werden kann. Auf der Maschinen-Seite wird vor die Concurrent Mark 2-Maschine ein Code-Loader gestellt, der den ASCII-Code liest und daraus wieder
die Datenstruktur für Programme erstellt.
Die Implementierung der abstrakten Maschinen wird einen Großteil der für das Praktikum eingeplanten Zeit beanspruchen, weswegen wir das eigentlich Compilieren möglichst zügig abschließen
wollen. Aus diesem Grund werden hier größere Programmteile (wie der Großteil des Parsers)
schon zur Verwendung vorgegeben.
2.5
Zeitplan
In Tabelle 2.1 ist der Zeitplan abgebildet, wobei das Projekt in sechs Projektabschnitte unterteilt
ist.
2.5.1
Präsentation der Ergebnisse
Ergebnisse sollen dreimal präsentiert werden:
• Nach Abschluss von Teil 2
• Nach Abschluss von Teil 3
• Nach Abschluss aller Teile.
Zu diesen drei Zeitpunkten soll auch der aktuelle Stand der Ausarbeitung sowie die bis dahin
vorhanden Programme abgegeben werden.
13
Projektabschnitt
Zugehörige
Aufgaben
beendet bis
(Bearbeitungszeit)
Teil 1:
Lexen und Parsen
Aufgabe 1
Aufgabe 2
Aufgabe 3
27. April
(2 Wochen)
Teil 2:
Semantische Analyse und Transformation in einfachere
Syntax
Aufgabe 4
Aufgabe 5
04. Mai
(1 Woche)
Teil 3:
Abstrakte Maschinen Mark 1 und Mark 2
Teil 4:
Nebenläufigkeit: Die Abstrakte Maschine Concurrent
Mark 2
Teil 5:
Codeerzeugung, Compiler, Interpreter und die Virtuelle
Maschine
Teil 6:
Verbesserungen: Garbage Collection
Aufgabe
Aufgabe
Aufgabe
Aufgabe
Aufgabe
Aufgabe
Aufgabe
Aufgabe
Aufgabe
Aufgabe
Aufgabe
Aufgabe
Aufgabe
Aufgabe
Aufgabe
Aufgabe
Aufgabe
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
Aufgabe 23
Tabelle 2.1: Zeitplan
14
01. Juni
(4 Wochen)
22. Juni
(3 Wochen)
6. Juli
(2 Wochen)
13. Juli
(1 Woche)
Kapitel 3
Der Compiler
Const ::= Var | Int | False | True | []
CExpr ::= letrec Binds in CExpr
| \Var -> CExpr
| AExpr
Binds ::= Bind ,Binds | Bind
AExpr ::= AExpr Expr
| Expr
Bind ::= Var =CExpr
Alts ::= Alt ,Alts | Alt
Expr ::=
|
|
|
|
|
|
|
|
|
|
|
|
amb Expr Expr
por Expr Expr
seq Expr Expr
null? Expr
Expr :Expr
(CExpr ,CExpr )
case Expr of { Alts }
Expr *Expr
Expr +Expr
Expr -Expr
\Var -> Expr
(CExpr )
Const
Alt ::= Pat -> CExpr
Pat ::= True | False | []
| Var :Var | (Var ,Var )
Abbildung 3.1: Die Syntax der Programmiersprache LFP+C
In Abbildung 3.1 ist die Syntax der Sprache dargestellt, wie sie im Parser verwendet wird. Hierbei
sind kursive Symbole Nichtterminale. Worte der Sprache LFP+C werden mit dem Nichtterminal
CExpr als Startsymbol gebildet. Desweiteren ist Int das Nichtterminal für (positive als auch
negative) Ganzahlen und Var sind Variablennamen, die aus Buchstaben und Zahlen bestehen
dürfen, jedoch mit einem Kleinbuchstaben beginnen müssen. In case-Alternativen müssen die
Pattern-Variablen verschieden sein, in letrec-Ausdrücken müssen die Variablen in den Bindungen paarweise verschieden sein.
Die Grammatik ist noch mehrdeutig, deswegen legen wir noch die folgenden Assoziativitäten fest:
letrec-Ausdrücke, Abstraktionen, case-Ausdrücke und der Listenkonstruktor (:) sind rechts
15
geklammert, während die Addition (+), Subtraktion (-) und die Multiplikation (*) wie üblich
links-assoziativ sind. Die Operatoren amb und por sind ebenfalls links zu klammern. Damit der
Rumpf einer Abstraktion und der Ausdruck nach dem in bei letrec-Ausdrücken möglichst weit
reichen, haben diese niedrigere Präzedenzen als die anderen Operationen. Damit die Punkt-vorStrich-Rechnung beachtet wird, haben + und - niedrigere Präzedenz als *.
Obige Grammatik lässt bei case-Alternativen auch mehrere doppelte“ Alternativen zu, wir
”
verbieten sie jedoch. Zuwenige Alternativen sind jedoch erlaubt, die fehlenden Alternativen
werden während des Parsens durch Dummy-Alternativen aufgefüllt.
3.1
Lexikalische Analyse
Die Grammatik zu LFP+C enthält keine Kommentare, wir nehmen jedoch an, dass Zeilenkommentare, eingeleitet durch --, bis zum Zeilenende möglich sind, ebenso wie beliebige Zeilenumbrüche, Leerzeichen und Tabulatoren. Diese werden bei der lexikalischen Analyse entfernt. Ziel
der Lexikalischen Analyse ist es, den reinen Quelltext in einen Strom (eine Liste) von Token zu
konvertieren.
Hierfür soll der Datentyp LFPCTok verwendet werden, der neben den Programmsymbolen auch
Markierungen über den Ort (Zeile, Spalte) des entsprechenden Tokens enthält. Dies dient zur
Produktion brauchbarer Fehlermeldungen.
> type CodeMark = (Int,Int) -- (Row, Column)
> data LFPCTok = TokInt
>
| TokVar
>
| TokTrue
>
| TokFalse
>
| TokNil
>
| TokCons
>
| TokPlus
>
| TokMinus
>
| TokMult
>
| TokIsNull
>
| TokLet
>
| TokIn
>
| TokCase
>
| TokOf
>
| TokComma
>
| TokCBOpen
>
| TokCBClose
>
| TokArrow
>
| TokLam
>
| TokBOpen
>
| TokBClose
>
| TokEq
>
| TokSeq
CodeMark Integer
CodeMark Var
CodeMark
CodeMark
CodeMark
CodeMark
CodeMark
CodeMark
CodeMark
CodeMark
CodeMark
CodeMark
CodeMark
CodeMark
CodeMark
CodeMark
CodeMark
CodeMark
CodeMark
CodeMark
CodeMark
CodeMark
CodeMark
16
------------------------
Ganzzahl
Variable
True
False
[]
:
+
*
null?
letrec
in
case
of
,
{
}
->
\
(
)
=
seq
>
>
| TokAmb
| TokPor
CodeMark
CodeMark
-- amb
-- por
Beispielsweise soll der Quelltext (letrec double = \x -> x+x in double) (-20) gerade den
Tokenstrom
[TokBOpen (1,1),TokLet (1,2),TokVar (1,9) "double",TokEq (1,16),
TokLam (1,18),TokVar (1,19) "x",TokArrow (1,21),
TokVar (1,24) "x",TokPlus (1,25),TokVar (1,26) "x",TokIn (1,28),
TokVar (1,31) "double",TokBClose (1,37),TokBOpen (1,39),
TokInt (1,40) (-20),TokBClose (1,43)]
erzeugen, während 10/10 einen Fehler ergibt, da / kein definiertes Symbol ist:
*** Exception: Error during lexing:
Line: 1
Column: 3
/10
Zur Verwendung des Minuszeichen sei noch folgende Konvention getroffen: folgt dem Minuszeichen direkt eine Zahl (ohne Leerzeichen), dann wird dies als eine negative Zahl interpretiert,
in allen anderen Fällen wird das Token für das Minuszeichen erstellt. Zur Demonstration der
Konvention geben wir noch drei Beispiele an:
Lexen von "3 - 3" ergibt:
[TokInt (1,1) 3,TokMinus (1,3),TokInt (1,5) 3]
Lexen von "3 -3" ergibt:
[TokInt (1,1) 3,TokInt (1,3) (-3)]
Lexen von "3 -(3+3)" ergibt:
[TokInt (1,1) 3,TokMinus (1,3),TokBOpen (1,4),
TokInt (1,5) 3,TokPlus (1,6),TokInt (1,7) 3,
TokBClose (1,8)]
Aufgabe 1. Implementieren Sie die folgenden Funktionen im Modul LFPC.Parse.Lex
• getCodeMark :: LFPCTok -> CodeMark, die die Positionsmarkierung eines Tokens zurück gibt.
• printLFPCTok :: LFPCTok -> String, die ein Token in den ursprünglichen
Quelltext des Tokens konvertiert.
• lexLFPC :: String -> [LFPCTok], die aus einem LFP+C -Quelltext eine Liste
von Token erstellt und dabei Kommentare entfernt und für jedes Token dessen
Position im Quelltext (Zeile, Spalte) mit abspeichert.
Sollte ein Fehler bei der lexikalischen Analyse auftreten, so generieren Sie mit der
error-Funktion eine Fehlermeldung, die zumindest die Zeile und Spalte des nicht
lexbaren Symbols und das Symbol selbst ausgibt.
17
3.2
Syntaktische Analyse
Der Parser wird mittels happy1 (siehe dazu auch Abschnitt 5.5) generiert, d.h. wir geben nur
eine Parserspezifikation an und lassen uns den Parser dann automatisch generieren. Die Spezifikationsdatei ist schon teilweise vorgegeben, um nicht all zuviel Zeit in das Erstellen eben dieser
zu investieren. Allerdings muss der Parser noch um einige Funktionalitäten vervollständigt werden, was deutlich wird, wenn wir einen Blick auf den Datentypen CoreLFPC für unsere (erste)
compiler-interne Kernsprache werfen:
> module LFPC.CoreL.CoreExpression where
> data CoreLFPC
>
= V CoreVar
>
| App CoreLFPC CoreLFPC
>
| Lambda CoreVar CoreLFPC
>
| Let [Bind] CoreLFPC
>
| Cons Int Int [CoreLFPC]
>
| Case CoreLFPC [Alt]
>
| Seq CoreLFPC CoreLFPC
>
| Amb CoreLFPC CoreLFPC
>
| Por CoreLFPC CoreLFPC
> deriving (Eq,Show)
----------
Variable
Applikation
Abstraktion
Letrec-Ausdruck
Konstruktoranwendung
Case-Ausdruck
Seq-Ausdruck
Amb-Ausdruck
Por-Ausdruck
> data Alt = Alt Int Int [CoreVar] CoreLFPC
> deriving (Eq,Show)
> data Bind =
CoreVar :=: CoreLFPC
> deriving (Eq,Show)
> type CoreVar = (CodeMark,Bool,Var,Var)
Die entsprechende Syntax kann durch die in Abbildung 3.2 abgebildete Grammatik dargestellt
werden.
Expr ::=
|
|
|
Pat ::=
Var | (Expr 1 Expr 2 ) | \Var -> Expr | ci,k Expr 1 . . . Expr k
letrec Var 1 = Expr 1 , . . . Var n = Expr n in Expr
case Expr of { Pat 1 -> Expr 1 , . . . , Pat n -> Expr n }
seq Expr 1 Expr 2 | amb Expr 1 Expr 2 | por Expr 1 Expr 2
ci,k Var 1 . . . Var k
Abbildung 3.2: Syntax von CoreLFPC
Während der Datentyp über Konstrukte für
1
http://haskell.org/happy
18
• Abstraktionen (Lambda CoreVar CoreLFPC)
• Applikationen (App CoreLFPC CoreLFPC)
• letrec-Ausdrücke (Let [Bind] CoreLFPC),
• case-Ausdrücke (Case CoreLFPC [Alt]),
• seq-Ausdrücke (Seq CoreLFPC CoreLFPC),
• amb-Ausdrücke (Amb CoreLFPC CoreLFPC),
• por-Ausdrücke (Por CoreLFPC CoreLFPC) und
• Variablen (V CoreVar)
verfügt, fehlen Konstrukte auf die sich der null?, +, - und der *-Operator oder Ganzzahlen
direkt abbilden lassen. Konstruktoren wie True, False, [] und : sowie Paare werden durch eine
einheitliche Darstellung mithilfe des Cons Int Int [CoreLFPC]-Konstruktes (in der Grammatik als ci,k Expr 1 . . . Expr k repräsentiert) realisiert. Hierbei ist das erste Argument (i) die
Nummer des Konstruktors, das zweite Argument (k) seine Stelligkeit und schließlich das dritte Argument die Liste der Argumente des Konstruktors (die gerade über k Elemente verfügt).
Neben diesen in LFP+C enthaltenen Konstruktoren fügen wir die zwei Konstruktoren Zero und
One und spezielle Paare |a,b| hinzu, die für die Darstellung der Ganzzahlen benötigt werden
(s.u.).
Nun müssen wir noch die verschiedenen Konstruktoren durchnummerieren und Stelligkeiten
vergeben, um eine einheitliche Darstellung zu erhalten. Diese Zuordnung ist Tabelle 3.1 zu
entnehmen.
[]
a:b
True
False
Zero
One
(a,b)
|a,b|
Cons
Cons
Cons
Cons
Cons
Cons
Cons
Cons
1
2
3
4
5
6
7
8
0
2
0
0
0
0
2
2
[]
[a,b]
[]
[]
[]
[]
[a,b]
[a,b]
Tabelle 3.1: Konstruktoren
3.2.1
Übersetzung
Wir definieren eine Übersetzung J·K, die Ausdrücke der Sprache LFP+C in den Datentypen
CoreLFPC übersetzt.
Zunächst betrachten wir die Übersetzung von Variablen:
JxK
=
V (pos(x),True,x,x)
19
Hier wird der Typ CoreVar verwendet, der aus vier Komponenten besteht
1. pos(x) ist die Position der Variablen x im Quelltext (vom Typ CodeMark).
2. Ein Boolescher Wert: True falls die Variable im Quelltext an dieser Position vorkommt,
False falls die Variable vom Compiler eingefügt wurde (s.u).
3. Der Name der Variablen im Quelltext
4. Der aktuelle Name der Variablen, dieser kann vom vorherigen Namen abweichen, da der
Compiler Umbenennungen durchführt.
Dieser Aufwand für Variablen wird betrieben, damit auch nach der syntaktischen Analyse, z.B.
während der semantischen Analyse für den Benutzer verständliche Fehlermeldungen generiert
werden können.
Betrachten wir nun die Übersetzung von Konstruktoren:
J[]K
JTrueK
JFalseK
Je1 : e2 K
J(e1 ,e2 )K
JZeroK
JOneK
J|e1 ,e2 |K
=
=
=
=
=
=
=
=
Cons
Cons
Cons
Cons
Cons
Cons
Cons
Cons
1
3
4
2
7
5
6
8
0
0
0
2
2
0
0
2
[]
[]
[]
[Je1 K,Je2 K]
[Je1 K,Je2 K]
[]
[]
[Je1 K,Je2 K]
Die Konstruktoren True, False, [], :, und Paare werden direkt in Konstruktoren gemäß Tabelle 3.1 übersetzt. Die Konstruktoren One, Zero und das spezielle Paar |a,b| gibt es im Quelltext
gar nicht, aber wir übersetzen sie hier auch, da wir sie später für die Kodierung von Ganzzahlen verwenden. Für die zweistelligen Konstrukte müssen selbstverständlich die Argumente auch
übersetzt werden.
Die Übersetzung von Zahlen ist anders (sonst bräuchten wir unendlich viele Konstruktoren). Wir
benutzen zur internen Darstellung die Binärdarstellung nicht-negativer Zahlen und speichern
zusätzlich, ob die Zahl positiv oder negativ ist, genauer:
JiK
=
J|s, bn (i): . . . :b1 (i):[]|K
wobei
½
• s=
½
• bj =
True, wenn i ≥ 0
False, sonst
Zero, wenn die j-te Stelle von i in Binärdarstellung 0 ist,
One, sonst
• n die Anzahl der Stellen von i in Binärdarstellung ist.
20
Das spezielle Paar und die Liste müssen natürlich noch entsprechend der Übersetzung J·K kodiert
werden.
Man beachte, dass die Binärdarstellung mit dem niedrigsten Bit beginnt. Diese Darstellung
ermöglicht eine effiziente Implementierung der Addition, Subtraktion und Multiplikation. Die
Verwendung spezieller Paare im Gegensatz zu normalen Paaren hat den Zweck, stets zu wissen,
dass es sich um eine Zahl handelt und nicht um ein beliebiges Paar. Das ist insbesondere notwendig, wenn wir das Ergebnis einer Berechnung wieder als Dezimalzahl drucken wollen. Wir
geben zur Verdeutlichung noch zwei Beispiele an:
J1K
J−2K
=
=
=
J|True, One:[]|K = Cons 8 2 [JTrueK,JOne:[]K]
Cons 8 2 [Cons 3 0 [], Cons 2 2 [JOneK,J[]K]]
Cons 8 2 [Cons 3 0 [], Cons 2 2 [Cons 6 0 [], Cons 1 0 []]]
=
=
J|False, Zero:One:[]|K = Cons 8 2 [JFalseK,JZero:One:[]K]
Cons 8 2
[Cons 4 0 [],
Cons 2 2 [Cons 5 0 [],Cons 2 2 [Cons 6 0 [],Cons 1 0 []]]]
Wir betrachten nun die Übersetzung der Abstraktionen, Applikationen, seq-Ausdrücke und der
beiden nichtdeterministischen Konstrukte.
J\x -> eK
Je1 e2 K
Jseq e1 e2 K
Jamb e1 e2 K
Jpor e1 e2 K
=
=
=
=
=
Lambda (pos(x),True,x,x) JeK
App Je1 K Je2 K
Seq Je1 K Je2 K
Amb Je1 K Je2 K
Por Je1 K Je2 K
Die Übersetzung ist relativ einfach, da die Konstrukte im Datentyp CoreLFPC genau vorhanden
sind. Bei der Übersetzung der Abstraktion ist zu beachten, dass die Variable wieder als 4-Tupel
übersetzt wird.
Die Übersetzung von letrec-Ausdrücken erfolgt ebenfalls direkt:
Jletrec bind1 , . . . , bindn in eK
Jx = eK
=
=
Let [Jbind1 K,, . . . ,Jbindn K] JeK
(pos(x),True,x,x) :=: JeK
Bei der Übersetzung von case-Ausdrücken und den zugehörigen Alternativen müssen fehlende
Alternativen ergänzt werden:
Jcase e of {alt1 , . . . altn }K
J[] -> eK
JTrue -> eK
JFalse -> eK
JZero -> eK
JOne -> eK
J(x:y) -> eK
J(x,y) -> eK
J|x,y| -> eK
=
=
=
=
=
=
=
=
=
Case JeK
Alt 1 0
Alt 3 0
Alt 4 0
Alt 5 0
Alt 6 0
Alt 2 2
Alt 7 2
Alt 8 2
(sort [Jalt1 K, . . . ,Jaltn K,altn+1 , . . . ,altm ])
[] JeK
[] JeK
[] JeK
[] JeK
[] JeK
[(pos(x),True,x,x),(pos(y),True,y,y)] JeK
[(pos(x),True,x,x),(pos(y),True,y,y)] JeK
[(pos(x),True,x,x),(pos(y),True,y,y)] JeK
21
Hierbei sind altn+1 , . . . altm Alternativen für Pattern, die nicht in den ersten n Alternativen vorkommen, wobei die rechten Seiten der zusätzlichen Alternativen aus der Variablen
V ((0,0),False,"_bot","_bot") bestehen, diese wird intern auf einen nichtterminierenden
Ausdruck abgebildet (durch die Funktion mkOuterLet, s.u.). Zusätzlich müssen die Alternativen
noch anhand der Konstruktornummer sortiert werden, was in der obigen Definition durch die
Funktion sort angedeutet ist.
Falls case-Ausdrücke doppelte Alternativen enthalten, d.h. Alternativen mit dem selben Pattern, so sollte ein Fehler beim Parsen auftreten. Dies wird in der Parser-Definition implementiert.
Es fehlt nun noch die Übersetzung der Operatoren +, -, * und null? auf Ganzzahlen. Diese
werden zunächst nur in Anwendungen auf Variablen übersetzt, wobei diese Variablen intern
sind, und deswegen spezielle Namen erhalten, die mit einem Unterstrich beginnen. Das hat
den Vorteil, dass sie nicht im Programm-Quelltext auftreten können, da sie bereits bei der
lexikalischen Analyse einen Fehler produzieren würden. Die Subtraktion (-) wird mithilfe der
Addition und der Multiplikation ausgedrückt.
Jnull?eK
Je1 +e2 K
Je1 -e2 K
Je1 *e2 K
=
=
=
=
App ((0,0),False,"_isNull","_isNull") JeK
App (App ((0,0),False,"_plus","_plus") Je1 K) Je2 K
Je1 +(-1*e2 )K
App (App ((0,0),False,"_mult","_mult") Je1 K) Je2 K
Die syntaktische Analyse besteht nun aus dem Parsen des Stromes von Token in die Datenstruktur CoreLFPC entsprechend der Übersetzung J·K und einem zusätzlichen abschließenden Schritt:
Wir müssen Funktionen für die Addition, den null?-Operator und eine Bindung für die interne
Variable "_bot" hinzufügen.
Das ist relativ leicht: Wir fügen um den geparsten Ausdruck, ein letrec-herum, dass Bindungen
für "_plus", "_mult", "_isNull" und "_bot" enthält. Dies erledigt die Funktion mkOuterLet
im Modul LFPC.Parse.InternalOp. Diese Funktion wurde mit einem erweiterten Parser, der
auch interne Variablen, die Konstruktoren One und Zero und |a,b|-Paare erkennt, aus dem
Quelltext in der Datei InternalOp.lfp generiert.
22
Aufgabe 2. Implementieren Sie in der Parserdefinition Parser.ly (aus der dann das
Modul LFPC.Parse.Parser generiert wird), die in den Aktionen verwendeten Funktionen
• mkInt :: Integer -> CoreLFPC die entsprechend der Übersetzung J·K eine Ganzzahl in die Darstellung innerhalb des Datentyps CoreLFPC konvertiert.
• checkBinds :: CoreLFPC -> [Bind] -> LFPCTok -> CoreLFPC, die den inAusdruck, die Bindungen, das Token des letrec-Ausdrucks erhält, und prüft, ob
alle Bindungsvariablen verschieden sind. Ist dies nicht der Fall, so wird eine Fehlermeldung mithilfe der im übergebenen Token steckenden Informationen generiert.
Andernfalls wird der letrec-Ausdruck in der Darstellung des Datentyps CoreLFPC
zurück gegeben.
• checkAlts :: CoreLFPC -> LFPCTok -> [Alt] -> CoreLFPC, die das erste Argument eines case-Ausdrucks, das Token für den case-Ausdruck und eine Liste
von case-Alternativen erhält und einen case-Ausdruck im Datentyp CoreLFPC erstellt, wobei
– geprüft werden muss, ob doppelte case-Alternativen enthalten sind. Ist dies
der Fall, so wird anhand des Tokens eine Fehlermeldung generiert. Man beachte, das die Grammatik case-Ausdrücke ohne Alternativen bereits verbietet,
und somit in diesem Fall eine Fehlermeldung generiert wird.
– fehlende Alternativen entsprechend der Übersetzung J·K hinzufügt werden,
– die Liste der Alternativen anhand der Konstruktornummern sortiert wird.
• chkAltPair
und
chkAltCons,
die
jeweils
die
Signatur
LFPCTok -> LFPCTok -> LFPCTok -> CoreLFPC -> Alt haben, wobei das
erste Token das Token für , oder : eines case-Patterns ist, das zweite Argument
die erste Patternvariable, das dritte Argument die zweite Patternvariable und das
vierte Argument die rechte Seite der Alternativen ist. Beide Funktionen prüfen ob
die Patternvariablen verschieden sind (andernfalls wird ein Fehler generiert), und
liefern anschließend eine Alternative.
Aufgabe 3. Geniereren Sie mittels happy den Parser aus der (vervollständigten) Parserspezifikation Parser.ly und testen Sie anschließend den Parser ausgiebig.
Wir geben noch einige Aufrufe des Parser an, die fehlschlagen sollten:
*> parseLFPC "letrec x=True, x=False in x"
*** Exception: multiple bindings in letrec expression for variable ’x’
Zeile: 1 Spalte: 1
*> parseLFPC "case True of {True -> False, True -> False}"
*** Exception: multiple case-alternatives for constructor "True"
Zeile: 1 Spalte: 1
23
*> parseLFPC "case [] of {(a:a) -> True}"
*** Exception: repeated variable ’a’ in pattern ":"
Zeile: 1 Spalte: 15
3.3
Semantische Analyse
Im Rahmen der semantischen Analyse werden wir zwei Aufgaben erledigen:
• Wir prüfen, ob das Programm ein geschlossener Ausdruck ist, d.h. keine ungebundenen
(freien) Variablen vorkommen.
• Wir benennen alle gebundenen Variablen mit neuen Namen um, so dass diese paarweise
verschieden sind.
Die Bindungsregeln von LFP+C sind wie folgt:
• In \x-> e ist x durch \x in e gebunden.
• In case e of { . . . , (x,y) -> e0 , . . . } sind x und y durch das Pattern (x,y) in e0 gebunden.
• In case e of { . . . , (x:y) -> e0 , . . . } sind x und y durch das Pattern (x,y) in e0 gebunden.
• In letrec x1 = e1 , . . . , xn = en in en+1 sind die Variablen x1 , . . . , xn durch die letrecBindungen in e1 , . . . , en+1 gebunden.
Variablen die nicht gebunden sind, sind frei.
Aufgabe 4. Implementieren Sie im Modul LFPC.SemAna.RenameBV die Funktion
renameLFPC :: CoreLFPC -> [Var] -> (CoreLFPC,[Var]),
die einen Ausdruck und eine Liste von neuen Variablennamen erwartet, und die gebundenen Variablen durch Verwendung der neuen Variablennamen umbenennt und schließlich
ein Paar bestehend aus dem umbenannten Ausdruck und den nicht verwendeten Variablennamen zurück gibt. Gleichzeitig soll dabei geprüft werden, ob freie Variablen im
Ausdruck vorkommen und in diesem Fall eine aussagekräftige Fehlermeldung generiert
werden.
Versuchen Sie obige Funktion möglichst effizient zu implementieren, z.B. sollte der Ausdruck nur einmal durchlaufen werden. Außerdem könnte die Datenstruktur Map aus dem
Modul Data.Map der Standardbibliotheken hilfreich sein.
Wir geben noch einige Beispiele an, die zu Fehlern führen:
*> (renameLFPC.parseLFPC) "(a b)" ["_internal" ++ show i | i <- [1..]]
*** Exception: Semantical error: Found unbound variable:a
Zeile: 1
Spalte: 2
24
*> (renameLFPC.parseLFPC) "letrec x=y in True" ["_internal" ++ show i | i <- [1..]]
*** Exception: Semantical error: Found unbound variable:y
Zeile: 1
Spalte: 10
*> (renameLFPC.parseLFPC) "case True of {(a:b) -> c }" ["_internal" ++ show i | i <- [1..]]
*** Exception: Semantical error: Found unbound variable:c
Zeile: 1
Spalte: 24
3.4
Transformation in CoreLFPCR
Sämtliche abstrakte Maschinen, die wir im nächsten Kapitel behandeln werden, können die im
Datentyp CoreLFPC vorliegende Kernsprache nicht verarbeiten. Die benötigte Restriktion ist,
dass sowohl das Argument einer Applikation als auch sämtliche Argumente einer Konstruktoranwendung nur Variablen – im Gegensatz zu beliebigen Ausdrücken – sein dürfen. Deswegen
werden wir die Sprache in einen Datentypen transformieren, der in den Argument-Positionen von
Applikationen und Konstruktoranwendungen nur Variablen zulässt. Dieser Datentyp CoreLFPCR
ist wie folgt im Modul LFPC.CoreL.MachineExp definiert.
> data CoreLFPCR =
>
App CoreLFPCR Var
>
| V Var
>
| Lambda Var CoreLFPCR
>
| Let [Bind] CoreLFPCR
>
| Cons Int Int [Var]
>
| Case CoreLFPCR [Alt]
>
| Seq CoreLFPCR CoreLFPCR
>
| Amb CoreLFPCR CoreLFPCR
>
| Por CoreLFPCR CoreLFPCR
> data Alt = Alt
Int Int [Var] CoreLFPCR
> data Bind = Var :=: CoreLFPCR
In Abbildung 3.3 ist die entsprechende Grammatik dargestellt.
Expr ::=
|
|
|
Pat ::=
Var | (Expr Var ) | \Var -> Expr | ci,k Var 1 . . . Var k
letrec Var 1 = Expr 1 , . . . Var n = Expr n in Expr
case Expr of { Pat 1 -> Expr 1 , . . . , Pat n -> Expr n }
seq Expr 1 Expr 2 | amb Expr 1 Expr 2 | por Expr 1 Expr 2
ci,k Var 1 . . . Var k
Abbildung 3.3: Syntax von CoreLFPCR
25
Man beachte, dass wir die Datentypen Alt und Bind neu definieren und die bekannten Datenkonstruktoren V, App, . . . benutzen, d.h. die beiden Module LFPC.CoreL.MachineExp und
LFPC.CoreL.CoreExpression dürfen niemals beide ohne Qualifizierung von einem Modul importiert werden (siehe Abschnitt 5.4).
Wir müssen nun den Datentyp CoreLFPC in CoreLFPCR konvertieren. Dabei müssen die folgenden
Transformationen durchgeführt werden:
Für jede Applikation:
(s t) → letrec y = t in (s y),
wobei y eine neue Variable ist.
Für jede Konstruktorapplikation (nicht in Pattern, nicht für 0-stellige Konstruktoren):
(c t1 . . . tn ) → letrec y1 = t1 , . . . , yn = tn in (c y1 . . . yn ),
wobei alle yi neue Variablen sind.
Beispiel 6. Wir transformieren den Ausdruck ((λx -> x) (λy -> y)) (True:[]), wobei i1 , . . . i4
neue Variablen sind:
((λx -> x) (λy -> y)) (True:[])
→ letrec i1 = (True:[]) in (((λx -> x) (λy
→ letrec i1 = (letrec i2 = True, i3 = [] in
in (((λx -> x) (λy -> y)) i1 )
→ letrec i1 = (letrec i2 = True, i3 = [] in
in ((letrec i4 = λy -> y in (λx -> x) i4 )
-> y)) i1 )
(i2 :i3 ))
(i2 :i3 ))
i1 )
Gleichzeitig werden wir bei dieser Transformation aus den als 4-Tupel dargestellten Variablen
(vom Typ CoreVar) nun Variablen vom Typ Var machen, d.h. wir behalten nur die aktuellen
Namen und werfen die restliche Information weg.
Aufgabe 5. Implementieren Sie im Modul LFPC.CoreL.TransCode eine Funktion
transLFPCtoLFCPR :: CoreLFPC -> [Var] -> (CoreLFPCR,[Var]), die einen Ausdruck vom Typ CoreLFPC und eine Liste (neuer) Variablennamen erhält und ein Paar
liefert, bestehend aus dem CoreLFPCR-Ausdruck und der Restliste von Variablennamen
(jene, die nicht benutzt wurden).
26
Kapitel 4
Verzögert auswertende Abstrakte
Maschinen
4.1
Die Abstrakte Maschine Mark 1 zur Ausführung deterministischer CoreLFPCR-Programme
Eine einfache abstrakte Maschine zur call-by-need Auswertung von letrec-Sprachen ist die
Mark 1 aus [15]. Die in [15] beschriebenen Maschinen passen zu unserer Sprache bis auf die
nichtdeterministischen Operatoren, da die Maschinen deterministisch sind. Deshalb werden wir
später die Mark 2 um nebenläufige Auswertung erweitern. Wir betrachten also zunächst nur
Ausdrücke vom Typ CoreLFPCR, die weder amb- noch por-Ausdrücke enthalten.
Wir benötigen noch die Definition der Werte, welche jene Ausdrücke sind, zu denen wir unsere
Programme auswerten möchten:
Definition 1. Ein Ausdruck ist ein Wert, wenn er eine Abstraktion, oder eine Konstruktorapplikation ist. Ein Ausdruck der Sprache CoreLFPCR ist in WHNF (weak head normal form,
schwache Kopfnormalform), wenn er eine der folgenden Formen besitzt:
• \x -> s, oder
• ci,k y1 . . . yk
• letrec x1 = s1 , . . . , xn = sn in v, wobei v ein Wert ist.
Der Zustand der Maschine ist ein Tripel (Γ, e, S) wobei:
• Γ ein Heap ist, der Heapbindungen enthält. Eine Heapbindung p 7→ e0 besteht aus einer
Heapvariablen p und einem Ausdruck e0 . Für jede Heapvariable darf nur eine Bindung im
Heap vorkommen, d.h. der Heap ist eine Abbildung von Heapvariablen auf Ausdrücke;
• e der aktuell auszuwertende Ausdruck ist. Wir nennen diese Komponente auch Control,
da sie die Auswertung (d.h. den Ablauf der Maschine) steuert.
• S ein Stack ist, der sich merkt“, wie man weitermachen muss, falls der aktuell auszuwer”
tende Ausdruck ein Wert ist.
27
pushSeq
takeSeq
−−−−−→
(Γ, e, #seq (e2 ) : S)
(Γ, e[p/y], S)
−−→
take
(Γ, e, #app (p) : S)
push
−−−→
mkBinds
−−−−−−→
(Γ∪·
{pi 7→ ebi }, eb, S)
i=1
n
S
· 7→ λy -> e}, λy -> e, S)
(Γ∪{p
−−−−→
update
(Γ0 , e, #heap (p) : S)
enter
−−−→
28
Beispiel 7. Wir demonstrieren die Auswertung des Ausdrucks
· 7→ ck,a p1 . . . pa }, ck,a p1 . . . pa , S)
(Γ∪{p
−−−−−→
branch
update2
(Γ, e, #case (alts) : S)
pushAlts
−−−−−→
Wir betrachten zunächst eine Beispielausführung der Maschine:
(Γ, λy -> e, #case (alts) : S) −−−−−−→ (Γ, λy -> e, #case (alts) : S)
blackhole3
(Γ, ck,a p1 . . . pa , #app (p) : S) −−−−−−→ (Γ, ck,a p1 . . . pa , #app (p) : S)
blackhole2
(Γ, p, S) −−−−−→ (Γ, p, S)
falls für p keine Bindung in Γ
blackhole
(Γ, ck,a p1 . . . pa , #case (alts) : S)
−−−−→
(Γ, e[p1 /y1 , . . . , pa /ya ], S)
wobei ck,a y1 . . . ya -> e die k-te Alternative in alts ist
(Γ, ck,a p1 . . . pa , #heap (p) : S)
(Γ, case e of alts, S)
wobei p1 , . . . , pn neue Pointer sind, d.h. weder in S noch in Γ vorkommen,
ebi = ei [p1 /x1 , . . . pn /xn ] und eb = e[p1 /x1 , . . . pn /xn ]
(Γ, letrec x1 = e1 , . . . , xn = en in e, S)
(Γ, λy -> e, #heap (p) : S)
· 7→ e}, p, S)
(Γ0 ∪{p
(Γ, v, #seq (e) : S) −−−−−→ (Γ, e, S)
wenn v ein Wert ist, d.h. v = (λy -> e0 ) oder v = (ck,a p1 . . . pa )
(Γ, (seq e1 e2 ), S)
(Γ, λy -> e, #app (p) : S)
(Γ, (e p), S)
Abbildung 4.1 zeigt die Übergangsrelation der Mark 1-Maschine, d.h. wie man aus einem Zustand
den darauf folgenden Zustand berechnet. Hierbei ist ∪· die disjunkte Vereinigung.
Abbildung 4.1: Zustandsübergangsregeln der Mark 1
Es fehlen jetzt noch Angaben, mit welchem Zustand man beginnt, und wann man aufhört:
Sei e ein CoreLFPCR Ausdruck, dann startet man mit dem Zustand (∅, e, []), d.h. mit leerem
Heap und leerem Stack.
Die Maschine stoppt, wenn keine Regel anwendbar ist, d.h. e ist eine Abstraktion oder eine
Konstruktoranwendung und der Stack ist leer.
letrec x = (λy -> y) z, z = c3,0 in x:
Heap
∅
Control
Stack
letrec x = (λy -> y) z, []
z = c3,0
in x
mkBinds
−−−−−−→ {p1 7→ ((λy -> y) z)[p1 /x, p2 /z], x[p1 /x, p2 /z]
p2 7→ c3,0 [p1 /x, p2 /z]}
= {p1 7→ (λy -> y) p2 p2 7→ c3,0 }
enter
−−−→ {p2 7→ c3,0 }
push
−−−→ {p2 7→ c3,0 }
take
−−→ {p2 7→ c3,0 }
= {p2 7→ c3,0 }
enter
−−−→ ∅
update2
−−−−−→ {p2 7→ c3,0 }
update2
−−−−−→ {p2 7→ c3,0 , p1 7→ c3,0 }
4.1.1
[]
p1
[]
(λy -> y) p2
[#heap (p1 )]
(λy -> y)
[#app (p2 ), #heap (p1 )]
y[p2 /y]
[#heap (p1 )]
p2
[#heap (p1 )]
c3,0
[#heap (p2 ), #heap (p1 )]
c3,0
[#heap (p1 )]
c3,0
[]
Implementierung
Zur Implementierung der Maschine sollte man nun genauer untersuchen, welche Datenstrukturen
und welche Operationen benötigt werden.
Wir benötigen drei Datenstrukturen:
Control: Hier ist die Datenstruktur bereits durch CoreLFCPR implementiert, allertake
mkBinds
dings brauchen wir eine zusätzliche Operation: In den Regeln −−→, −−−−−−→
branch
und −−−−→ werden Variablen durch Heapvariablen ersetzt. Wir benutzen für
Heapvariablen in der Mark 1 ebenfalls Strings. D.h. es fehlt eine Funktion
substitute :: CoreLFPCR -> Var -> Var -> CoreLFPCR, die einen Ausdruck und zwei
Variablen erhält und im Ausdruck die erste durch die zweite Variable ersetzt.
Heap: Heapeinträge sind Paare vom Typ (Var,CoreLFPCR). Da der Heap eine Abbildung von
Variablen auf Ausdrücke ist, bietet es sich an den Datentyp Map aus der Standardbibliothek
Data.Map zu verwenden, d.h. wir definieren den Typ Heap als
type Heap = Map Var CoreLFPCR
Wir benötigen die folgenden Operationen auf dem Heap:
enter
blackhole
• Für die Regeln −−−→ und −−−−−→:
lookupHeap : Var -> Heap -> Maybe (CoreLFPCR,Heap), die eine Heapvariable
und einen Heap erhält, und
29
– falls eine Bindung für die Heapvariable existiert, die Bindung aus dem Heap
entfernt und das Paar bestehend dem Ausdruck und dem modifizierten Heap
liefert,
– Nothing liefert falls keine Bindung existiert.
update
update2
mkBinds
• Für die Regeln −−−−→, −−−−−→ und −−−−−−→:
insertHeap : Var -> CoreLFPCR -> Heap -> Heap, die eine Variable, einen Ausdruck und einen Heap erhält und eine neue Bindung auf dem Heap anlegt, und schließlich den geänderten Heap zuückgibt.
• emptyHeap :: Heap zum Erstellen eines leeren Heaps.
Stack: Stackelemente können Variablen aus einer Applikation #app (p), Variablen aus dem
Heap #heap (p), case-Alternativen (#case (alts)), oder rechte Ausdrücke von seq-Ausdrücken
(#seq (e)) sein. D.h. die Elemente könnten durch den Datentypen
data StackElem = RetApp Var | RetHeap Var | RetCase [Alt] | RetSeq CoreLFPCR
repräsentiert werden. Der Stack selbst kann dann durch eine Liste von StackElemElementen dargestellt werden, d.h.
type Stack = [StackElem]
Operationen auf dem Stack sind:
push
enter
pushAlts
pushSeq
• Für
die
Regeln
−−−→,
−−−→,
−−−−−→
und
−−−−−→,
push :: StackElem -> Stack -> Stack, welche ein Stack-Element auf den
Stack legt.
take
takeSeq
update
update2
• Für
die
Regeln
−−→,
−−−−−→,
−−−−→,
−−−−−→
pop :: Stack -> (StackElem,Stack), welche das oberste Element des Stacks
nimmt.
• isEmptyStack :: Stack -> Bool, die prüft, ob der Stack leer ist.
• emptyStack :: Stack, die einen neuen leeren Stack anlegt.
Zusätzlich werden Operationen auf dem StackElem-Typ benötigt.
Wir könnten nun direkt loslegen und die Datenstrukturen wie eben analysiert implementieren.
Allerdings benötigen die Maschinen Mark 2 und Concurrent Mark 2 auch andere Elemente im
Stack und im Heap. Deswegen werden diese Datenstrukturen in der folgenden Aufgabe polymorph über den Elementtypen definiert und implementiert.
30
Aufgabe 6. Implementieren Sie im Modul LFPC.AbsM.Heap einen Datentypen für den
Heap, der polymorph über den Heapvariablen und den rechten Seiten der Bindungen ist,
d.h.
type Heap p e = ...
Implementieren Sie im gleichen Modul die folgenden Operationen auf dem Datentypen
• emptyHeap :: Heap p e zum Erstellen eines leeren Heaps.
• lookupHeap :: p -> Heap p e -> Maybe (e, Heap p e), die wie oben beschrieben im Heap nach dem Eintrag für p sucht und die rechte Seite der Bindung,
sowie den modifizierten Heap liefert. Eventuell müssen Sie an den Typ p weitere
Typklassen-Anforderungen stellen.
• insertHeap :: p -> e -> Heap p e -> Heap p e, die wie oben beschrieben
eine Heapvariable vom Typ p und eine rechte Seite vom Typ e sowie einen Heap
erhält und die entsprechende neue Bindung im Heap anlegt und schließlich den
modifizierten Heap zurück liefert. Auch hier ist es möglich, dass an p weitere
Typklassen-Anforderung gestellt werden müssen.
Versuchen Sie eine möglichste effiziente Datenstruktur zu wählen.
Aufgabe 7. Implementieren Sie im Modul LFPC.AbsM.Stack einen Datentypen für den
Stack, der polymorph über den Einträgen im Stack ist
type Stack a
= ...
Implementieren Sie im gleichen Modul die folgenden Operationen auf dem Stack:
• emptyStack :: Stack a zum Erzeugen eines leeren Stacks.
• isEmptyStack :: Stack a -> Bool, die prüft ob der Stack leer ist.
• push :: a -> Stack a -> Stack a, die ein Element und einen Stack erhält und
das Element oben auf den Stack legt.
• pop :: Stack a -> (a, Stack a), die das oberste Element vom Stack nimmt
und das Paar bestehend aus diesem Element und dem Reststack zurück gibt.
31
Aufgabe 8. Im Modul LFPC.AbsM.Stack.StackElem ist der gleichnamige Datentyp definiert als
> data StackElem v a b = RetApp v | RetHeap v
> deriving (Eq,Show)
| RetCase a | RetSeq b
D.h. Stack-Elemente sind polymorph über den Variablen v, den Alternativen a und den
Ausdrücken b. Sie können entweder Variablen aus einer Anwendung, Variablen aus dem
Heap, case-Alternative oder rechte Argumente (Ausdrücke) von seq-Ausdrücken sein.
Später werden wir auch tatsächlich etwas anderes als reine case-Alternativen auf dem
Stack ablegen. Implementieren Sie die folgenden Funktion diesem Modul:
• isRetApp :: StackElem v a b -> Bool, die testet, ob ob ein Stack-Element von
der Form Retapp ... ist.
• isRetHeap :: StackElem v a b -> Bool, die testet, ob ob ein Stack-Element
von der Form RetHeap ... ist.
• isRetCase :: StackElem v a b -> Bool, die testet, ob ob ein Stack-Element
von der Form RetCase ... ist.
• isRetSeq :: StackElem v a b -> Bool, die testet, ob ob ein Stack-Element von
der Form RetSeq ... ist.
• mkRetHeap :: v -> StackElem v a b die aus einer Variablen ein RetHeapObjekt erstellt.
• mkRetApp :: v -> StackElem v a b, die aus einer Variablen ein RetApp-Objekt
erstellt.
• mkRetCase :: a -> StackElem v a b, die aus ein RetCase-Objekt erstellt.
• mkRetSeq :: b -> StackElem v a b, die aus ein RetSeq-Objekt erstellt.
• fromVar :: StackElem v a b -> v, die aus einem RetApp oder RetHeap-Objekt
die Variable extrahiert.
• fromRetCase :: StackElem v a b -> a, die aus einem RetCase-Objekt die Alternativen extrahiert.
• fromRetSeq :: StackElem v a b -> b, die aus einem RetSeq-Objekt die Alternativen extrahiert.
Aufgabe 9. Implementieren Sie im Modul LFPC.CoreL.MachineExp eine Funktion
substitute :: CoreLFPCR -> Var -> Var -> CoreLFPCR
die einen Ausdruck und zwei Variablen erhält und alle Vorkommen der ersten Variablen
durch die zweite ersetzt.
32
Nachdem nun alle notwendigen Datenstrukturen und Operationen vorhanden sind, können wir
die Mark 1-Maschine implementieren.
Im Modul LFPC.AbsM.Mark1 wird der Datentyp für den Zustand der Mark 1 definiert (unter
Benutzung der Record-Syntax (siehe Abschnitt 5.2)):
> data Mark1State = Mark1State {heap :: Heap Var CoreLFPCR ,
>
control :: CoreLFPCR,
>
stack :: (Stack (StackElem Var [Alt] CoreLFPCR))}
Der Zustand besteht somit aus den 3 Komponenten:
• dem Heap vom Typ (Heap Var CoreLFPCR), d.h. einer Abbildung von Variablen auf
CoreLFPCR-Ausdrücke;
• Control, als Ausdruck vom Typ CoreLFPCR;
• dem Stack vom Typ Stack (StackElem Var [Alt] CoreLFPCR), d.h. für die Variablen
setzen wir den Typ Var, für die Alternativen den Typ [Alt] und für die Ausdrücke aus
dem seq den Typ CoreLFPCR ein.
Aufgabe 10. Implementieren Sie im Modul LFPC.AbsM.Mark1 die folgenden Funktionen:
• startState :: CoreLFPCR -> Mark1State, die einen CoreLFPCR-Ausdruck
erhält und den Startzustand der Maschine für diesen Ausdruck berechnet.
• nextState :: Mark1State -> [Var] -> (Mark1State, [Var]), die einen Zustand der Mark 1 sowie eine Liste (neuer) Variablen erhält, und entsprechend der
Übergangsrelation den Folgezustand berechnet und zusätzlich die nicht benutzten
neuen Variablen zurück gibt.
• finalState :: Mark1State -> [Var] -> (Mark1State, [Var]), die einen Zustand der Mark 1 sowie eine Liste (neuer) Variablen erhält, und entsprechend den
Endzustand der Maschine berechnet und zusätzlich die nicht benutzten neuen Variablen zurück gibt.
• exec :: CoreLFPCR -> [Var] -> String, die einen Ausdruck vom Typ
CoreLFPCR und eine Liste neuer Variablennamen erhält, anschließend die
Mark 1-Maschine für diesen Ausdruck ausführt und schließlich das Ergebnis
zurück in einen String konvertiert. Hierbei sollen Zahlen, Listen und Paare wieder in ihrer gebräuchlichen Darstellung ausgegeben werden. Z.B. soll
beim Ergebnis "15" genau dieser String zurückgegeben werden (im Gegensatz zu
Cons 8 2 [Cons 3 0,Cons 2 2 [Cons 6 0,Cons 2 2 [Cons 6 0,Cons 1 1]]]).
Hinweis: Da die Mark 1 Maschine nur bis zur WHNF auswertet, müssen Sie die
Machine zum Drucken des Ergebnisses erneut mit den Argumenten eines Konstruktors aufrufen.
33
Aufgabe 11. Implementieren Sie im Modul LFPC.Run eine Funktion runMark1, die
ein deterministisches LFP+C -Programm erwartet, dann das Programm lext, parst, der
semantischen Analyse unterzieht, in CoreLFPCR umwandelt, schließlich auf der Mark 1Maschine laufen lässt und das Ergebnis als String zurück liefert.
Für die neuen Variablen, die Sie brauchen werden, verwenden Sie interne Variablen,
z.B. die Liste ["_internal" ++ show x | x <- [1..]].
Durch Lösung der letzten Aufgabe haben wir bereits einen lauffähigen Interpreter für LFP+C
ohne amb und por erstellt. Allerdings kann dieser noch verbessert werden, was in den nächsten
Abschnitten geschehen wird. Anschließend werden wir die Maschine noch um nebenläufige Auswertung erweitern.
4.2
Verbesserungen von Mark 1 – Mark 2
Ein Schwachpunkt der Mark 1-Maschine ist das Ersetzen von Variablen für Variablen in den
take
mkBinds
branch
Regeln −−→, −−−−−−→ und −−−−→. Dieses Substituieren ist zum einen sehr aufwendig, zum
anderen verhindert es das effiziente Abarbeiten von sequentiellem Code, da bei der Substitution
komplette Ausdrücke durchlaufen werden müssen.
Deswegen wird in der Mark 2 Maschine eine zusätzliche Komponente zum Zustand der Maschine
hinzugefügt, eine Umgebung (Environment), welche eine Abbildung von Programmvariablen auf
Heapvariablen darstellt. Mithilfe dieser Umgebung werden die Substitutionen quasi verzögert“
”
ausgeführt, d.h. erst dann, wenn sie gebraucht werden.
Formaler ist der Zustand der Mark 2-Maschine ein 4-Tupel (Γ, e, E, S) wobei:
• Γ ein Heap ist, der Heapbindungen enthält. Eine Heapbindung p 7→ (e0 , E 0 ) besteht aus einer
Heapvariablen p und einem Paar (Ausdruck e0 , Umgebung E 0 ). Für jede Heapvariable darf
nur eine Bindung im Heap vorkommen, d.h. der Heap ist eine Abbildung.
• e der aktuell auszuwertende Ausdruck ist.
• Der aktuellen Umgebung passend zum aktuell auszuwertenden Ausdruck, die eine Abbildung von Programmvariablen auf Heapvariablen ist.
• S ein Stack ist.
Ein interessanter Nebeneffekt bei der Einführung der Umgebung ist, dass Programmvariablen
und Heapvariablen nicht mehr vom gleichen Typ sein müssen. Deswegen werden wir Heapvariablen ab sofort durch Integer-Zahlen darstellen.
Abbildung 4.2 zeigt die Übergangsrelation für die Mark 2-Maschine. Die einzelnen Regeln entsprechen den Regeln der Mark 1-Maschine, wobei es jedoch keine Variablen-Substitutionen mehr
gibt, stattdessen wird die Umgebung modifiziert.
Die Maschine startet mit leerem Heap, leerer Umgebung und leerem Stack. Sie akzeptiert wie
vorher, falls der Stack leer ist und der auszuwertende Ausdruck eine Konstruktorapplikation
oder eine Abstraktion ist.
34
Abbildung 4.2: Zustandsübergangsregeln der Mark 2
35
pushSeq
takeSeq
−−−−−→
(Γ, e1 , E, #seq (e2 , E) : S)
· 7→ p}, S)
(Γ, e, E ∪{y
−−→
take
·
(Γ, e, E ∪{x
7→ p}, #app (p) : S)
push
−−−→
mkBinds
−−−−−−→
(Γ∪·
b e, E,
b S)
{pi 7→ (ei , E)},
i=1
n
S
· 7→ (λy -> e, E)}, λy -> e, E, S)
(Γ∪{p
−−−−→
update
(Γ0 , e0 , E 0 , #heap (p) : S)
enter
−−−→
· 7→ (ck,a x1 . . . xa , E)}, ck,a x1 . . . xa , E, S)
(Γ∪{p
−−−−−→
branch
update2
(Γ, e, E, #case (alts, E) : S)
pushAlts
−−−−−→
(Γ, λy -> e, E, #case (alts, E 0 ) : S) −−−−−−→ (Γ, λy -> e, E, #case (alts, E 0 ) : S)
blackhole3
(Γ, ck,a x1 . . . xa , E, #app (p) : S) −−−−−−→ (Γ, ck,a x1 . . . xa , E, #app (p) : S)
blackhole2
·
·
(Γ, x, E ∪{x
7→ p}, S) −−−−−→ (Γ, x, E ∪{x
7→ p}, S)
falls für p keine Bindung in Γ
blackhole
c0 , S)
(Γ, ck,a x1 . . . xa , E, #case (alts, E 0 ) : S)
−−−−→
(Γ, e, E
wobei ck,a y1 . . . ya -> e die k-te Alternative in alts ist
c0 = E 0 ∪· Sa {yi 7→ E(xi )}
und E
i=1
(Γ, ck,a x1 . . . xa , E, #heap (p) : S)
(Γ, case e of alts, E, S)
b = E ∪{x
· 1 7→ p1 , . . . xn 7→ pn } und p1 , . . . , pn neue Pointer sind,
wobei E
d.h. weder in S noch in Γ vorkommen
(Γ, letrec x1 = e1 , . . . , xn = en in e, E, S)
(Γ, λy -> e, E, #heap (p) : S)
·
· 7→ (e0 , E 0 )}, x, E ∪{x
(Γ0 ∪{p
7→ p}, S)
(Γ, v, E, #seq (e, E 0 ) : S) −−−−−→ (Γ, e, E 0 , S)
wenn v ein Wert ist.
(Γ, seq e1 e2 , E, S)
(Γ, λy -> e, E, #app (p) : S)
·
(Γ, (e x), E ∪{x
7→ p}, S)
Bevor wir uns der Implementierung widmen, demonstrieren wir den Ablauf der Maschine anhand
eines Beispiels:
Beispiel 8. Wir demonstrieren die Auswertung der Mark 2-Maschine anhand des Ausdrucks
letrec x = (λy -> y) z, z = c3,0 in x:
Heap
∅
Control
Environment
letrec
∅
x = (λy -> y) z
z = c3,0
in x
Stack
[]
mkBinds
{x 7→ 1, z 7→ 2}
[]
enter
{x 7→ 1, z 7→ 2}
[#heap (1)]
push
{x 7→ 1, z 7→ 2}
[#app (2), #heap (1)]
−−−→ {2 7→ (c3,0 , {x 7→ 1, z 7→ 2})} y
take
{x 7→ 1, z 7→ 2, y 7→ 2} [#heap (1)]
enter
c3,0
{x 7→ 1, z 7→ 2}
[#heap (2), #heap (1)]
−−−−−→ {2 7→ (c3,0 , {x 7→ 1, z 7→ 2})} c3,0
update2
{x 7→ 1, z 7→ 2}
[#heap (1)]
update2
{x 7→ 1, z 7→ 2}
[]
−−−−−−→ {1 7→ (((λy -> y) z),
x
{x 7→ 1, z 7→ 2}),
2 7→ (c3,0 , {x 7→ 1, z 7→ 2})}
−−−→ {2 7→ (c3,0 , {x 7→ 1, z 7→ 2})} ((λy -> y) z)
−−−→ {2 7→ (c3,0 , {x 7→ 1, z 7→ 2})} (λy -> y)
−−−→ ∅
−−−−−→ {2 7→ (c3,0 , {x 7→ 1, z 7→ 2}), c3,0
1 7→ (c3,0 , {x 7→ 1, z 7→ 2})}
4.2.1
Implementierung
Aufgabe 12. Implementieren Sie im Modul LFPC.AbsM.Environment einen polymorphen Datentypen Environment a b, der eine Abbildung von Werten des Types a auf
Werte des Types b darstellt.
Implementieren Sie des weiteren die Operationen
• emptyEnv :: Environment a b zum Erzeugen einer leeren Umgebung
• lookupEnv :: a -> Environment a b -> b, welche einen Wert vom Typ a und
eine Umgebung erhält und den Eintrag für den Wert berechnet, d.h. ein Ergebnis
vom Typ b liefert. (Im Gegensatz zu lookupHeap) wird die Umgebung dabei nicht
verändert, d.h. es wird nur gelesen). Falls kein Eintrag für den übergebenen Wert
vorhanden ist, soll ein Laufzeitfehler auftreten.
• insertEnv :: a -> b -> Environment a b -> Environment a b, die einen
Wert vom Typ a und einen Wert vom Typ b als neue Abbildung in die im dritten Argumenten übergebene Umgebung einfügt und schließlich die Umgebung als
Resultat liefert.
Benutzen Sie eine für diese Operationen möglichst effiziente Datenstruktur.
36
Da wir nun auch Umgebungen mit den benötigten Operationen implementiert haben, können
wir nun die Mark 2-Maschine implementieren. Im Modul LFPC.Abs.Mark2 ist der Zustand der
Mark 2 Maschine durch den Typ Mark2State vorgegeben als:
> type Mark2Environment =
(Environment Var Integer)
> type Mark2Stack = Stack
>
(StackElem
>
Integer
>
([Alt], Mark2Environment)
>
(CoreLFPCR,Mark2Environment)
>
)
> data Mark2State = Mark2State
>
{ heap
>
control
>
stack
>
environment
>
}
::
::
::
::
Heap Integer (CoreLFPCR, Mark2Environment),
CoreLFPCR,
Mark2Stack,
Mark2Environment
Verglichen mit Mark1State ist die Umgebungskomponente environment neu, die nun für jeden
Ausdruck vorhanden sein muss (insbesondere auch bei den im Heap gespeicherten Ausdrücken
und den case-Alternativen, und den vom seq stammenden Ausdruck auf dem Stack). Außerdem
werden Heapvariablen nun durch Integer-Werte und nicht mehr durch Variablen vom Typ Var
dargestellt. Man beachte, dass die auf den Stack geschobenen Variablen immer Heapvariablen
push
sind (die Regel −−−→ legt nämlich nicht mehr die Variable, sondern den der Variablen zugehörigen
Pointer auf den Stack).
37
Aufgabe 13. Implementieren Sie im Modul LFPC.Abs.Mark2 die folgenden Funktionen:
• startState :: CoreLFPCR -> Mark2State, die für einen CoreLFPCR-Ausdruck
den Startzustand der Mark 2-Maschine berechnet.
• nextState :: Mark2State -> Counter -> (Mark2State,Counter), die einen
Mark2State Zustand und einen Zähler erwartet. Der Typ Counter ist im Modul
LFPC.Util.Declarations gerade definiert als
type Counter = Integer
Der Zähler wird zum Generieren neuer Heapvariablen verwendet (d.h. der aktuelle
Zähler wird die aktuelle neue Heapvariable, anschließend wird der Zähler inkrementiert),
Das Ergebnis der Funktion nextState ist der Folgezustand der Maschine und der
aktuelle Wert des Zählers.
• finalState :: Mark2State -> Counter -> (Mark2State, Counter),
die
einen Zustand der Mark 2-Maschine sowie einen Counter erwartet und das Paar
bestehend aus dem Endzustand und dem neuem Counter liefert.
• exec :: CoreLFPCR -> Counter -> String, die einen (deterministischen)
CoreLFPCR-Ausdruck und einen Counter erwartet und dann das Ergebnis des
Ablaufs der Mark 2-Maschine als String ausgibt. Hierbei sollen Zahlen in ihrer
Dezimaldarstellung ausgegeben werden.
Aufgabe 14. Implementieren Sie im Modul LFPC.Run eine Funktion runMark2, die
ein deterministisches LFP+C -Programm erwartet, dann das Programm lext, parst, der
semantischen Analyse unterzieht, in CoreLFPCR umwandelt, schließlich auf der Mark 2Maschine laufen lässt und das Ergebnis als String zurück liefert.
4.3
Nebenläufigkeit – Concurrent Mark 2
Bisher können wir nur deterministische LFP+C -Programme ausführen, d.h. die Programme
dürfen weder por- noch amb-Ausdrücke enthalten. In diesem Abschnitt erweitern wir die Mark 2Maschine um Nebenläufigkeit um dann sämtliche LFP+C -Programme ausführen zu können. Die
Grundlage dieser Maschine bildet die Untersuchung einer ähnlichen Maschine (ohne por aber
für amb) aus [10], die direkt auf der Mark 1-Maschine aufbaut. Diese Untersuchung ist ähnlich
zur Maschine von Moran in [8], allerdings eine verbesserte Version.
Wir erweitern den Zustand der Mark 2-Maschine um eine Folge von Threads (genauer eine
hierarchische Baumstruktur), jeder dieser Threads hat einen eigenen aktuell auszuwertenden
Ausdruck, eine eigene Umgebung und einen eigenen Stack. Der Heap ist jedoch global, d.h. er
steht allen Threads zur Verfügung.
38
Bei Auswertung eines Ausdrucks (amb e1 e2 ) sollen nun zwei nebenläufige Threads für die Auswertung von e1 bzw. e2 gestartet werden. Sobald ein Wert bei einem der Threads erhalten ist,
wird dieser als Wert des (amb e1 e2 ) übernommen und die anderen erzeugten Threads beendet.
Bei dieser Auswertung ist Fairness erforderlich, es darf nicht sein, dass z.B. ständig der zu e1
zugehörige Thread ausgewertet wird, aber nie terminiert, während der zu e2 zugehörige Thread
in endlich vielen Schritten zu einem Wert auswerten würde, aber nie dran kommt.
Um Fairness zu erreichen werden Ressourcen verteilt: Jeder Thread besitzt eine Ressource (nichtnegative Ganzzahl), jeder Auswertungsschritt des Threads erniedrigt die Ressource und die
Ressourcen aller Vaterprozesse um 1. Wenn die Ressource 0 ist, muss der Thread warten. Das
Verteilen der Ressourcen (Scheduling) ist erst möglich wenn die Ressourcen aller konkurrierender
Threads 0 sind. Dann muss jeder Thread eine Ressource größer als 0 erhalten.
4.3.1
Nebenläufigkeit und Prozessbäume
Prozessbäume sind binäre Bäume mit Markierungen an den Knoten. An den inneren Knoten
steht ein Stack, eine Markierung, ob die Verzweihung durch einen amb- oder einen por-Ausdruck
enstanden ist und zwei Ressourcen (nicht-negative Ganzzahlen), und an den Blättern stehen
Threads. Formal können solche Prozessbäume wie folgt definiert werden.
Definition 2. Die Menge der Prozessbäume PT ist induktiv durch die folgenden Regeln definiert:
• Wenn s ein Thread ist, dann ist leaf(s) ∈ PT ,
• Wenn P T1 , P T2 ∈ PT Prozessbäume sind, S ein Stack ist, m1 , m2 ∈ Nat 0 und ndV al ∈
{amb, por}, dann ist node(S, ndV al, m1 , m2 , P T1 , P T2 ) ∈ PT .
Im Modul LFPC.Util.PTree sind Prozessbäume polymorph über den Blatt- und den KnotenMarkierungen, die später Stacks enthalten, definiert als
> data PTree a b =
> -- Knoten:
>
Node a NDVal Integer Integer (PTree a b) (PTree a b)
> -- Blatt:
>>
| Leaf b
wobei NDVal definiert is als
> data NDVal = AMB | POR
Zur Darstellung der Maschinentransitionen benutzen wir auch Baumkontexte: Ein Baumkontext
T R ∈ T R ist wie ein Prozessbaum, nur dass ein Teilbaum durch ein Loch ersetzt wurde.
Das Loch stellen wir mit J¦K dar. D.h. Baumkontexte sind als Funktionen zu sehen, die einen
Prozessbaum als Argument erwarten und daraus einen neuen Prozessbaum erstellen, indem sie
den übergebenen Baum in das Loch einsetzen. Sei T R ein Baumkontext, P T ein Prozessbaum,
dann schreiben wir die Einsetzung von P T in T R als T RJP T K.
39
We bereits erwähnt benutzen wir einen Prozessbaum, um die Menge der auszuwertenden Threads
hierarchisch anzuordnen. Der Zustand der Concurrent Mark 2 ist somit ein Paar (Γ, P T ) bestehend aus einem Heap Γ und einem Prozessbaum P T , wobei die Blätter des Prozessbaums mit
Threads der Form (e, E, S) markiert sind: Hier ist e der vom Thread auszuwertende Ausdruck,
E die Umgebung bestehend aus einer Abbildung von Variablen auf Heappointer und S ein Stack,
der genau die gleichen Elemente enthalten kann, wie Stacks der Mark 2-Maschine.
Im Modul LFPC.AbsM.ConcurrentMark2 sind die entsprechenden Datentypen für den Zustand,
und einzelne Threads definiert als
> type CMark2Heap = Heap Integer (CoreLFPCR, M2.Mark2Environment)
> type CMark2PTree = PTree M2.Mark2Stack CMark2Thread
> data CMark2State =
>
CMark2State {
>
heap
:: CMark2Heap,
>
ptree
:: CMark2PTree
>
}
> data CMark2Thread
>
CMark2Thread{
>
control
>
stack
>
environment
>
}
=
:: CoreLFPCR,
:: M2.Mark2Stack,
:: M2.Mark2Environment
Wir verwenden hier einige Datenstrukturen der Mark 2-Maschine wieder. Deswegen wird diese
am Anfang des Moduls LFPC.AbsM.ConcurrentMark2 mittels
> import qualified LFPC.AbsM.Mark2 as M2
importiert.
Für einen Ausdruck e ist der Anfangszustand der Maschine gerade das Paar (∅, leaf(e, ∅, [])), d.h.
der Heap ist der leer, der Prozessbaum besteht aus einem einzigen Blatt, dessen Thread eine
leere Umgebung und einen leeren Stack besitzt und den Ausdruck e auswerten möchte.
Aufgabe 15. Implementieren Sie im Modul LFPC.AbsM.ConcurrentMark2 die Funktion
startState :: CoreLFPCR -> CMark2State, die für einen CoreLFPCR Ausdruck den
Startzustand der Concurrent Mark 2-Maschine berechnet.
Akzeptierende Zustände sind von der Form (Γ, leaf(v, E, [])) wobei v ein Wert, d.h. eine Abstraktion oder eine Konstruktorapplikation, ist.
Die Transitionsregeln der Concurrent Mark 2 sind in Abbildung 4.3 definiert. Dort werden noch
keine Ressourcen verwendet, und die Spezifikation ist noch nicht vollständig, da die Funktion
nfΦ noch nicht definiert ist. Außerdem werden dort zur besseren Lesbarkeit True und False
anstelle der Konstruktoren c3,0 und c4,0 verwendet.
40
Abbildung 4.3: Zustandstransition der Concurrent Mark 2 (ohne Ressourcen)
41
a
(Γ, T RJnode(S, por, (leaf(s, E, [])), (leaf(t, E, [])))K)
−−−−−−→
choose−amb−l
f ork−por
(Γ, T RJnode(S, amb, (leaf(s, E, [])), (leaf(t, E, [])))K)
f ork−amb
−−−−−−→
choose−por−f alse
blackhole4
−−−−−−−−−−−→
(Γ, T RJleaf(False, E1 , [])K)
(Γ, T RJnode(S, por, P Tn , leaf(v, E, []))K)
−−−−−−→
(Γ, T RJnode(S, por, P Tn , leaf(v, E, []))K)
falls v ein Wert ist, keine andere Regel anwendbar ist.
blackhole5
(Γ, T RJnode(S, por, leaf(v, E, [])), P Tm K)
−−−−−−→
(Γ, T RJnode(S, por, leaf(v, E, [])), P Tm K)
falls v ein Wert ist, keine andere Regel anwendbar ist.
(Γ, T RJnode(S, por, leaf(False, E1 , []), leaf(False, E2 , []))K)
(Γ, T RJnode(S, por, P Tn , leaf(True, E, []))K) −−−−−−−−−−−−→ (Γ0 , T RJleaf(True, E, S)K)
falls nfΦ (Γ, P Tn ) = Γ0
choose−por−true−r
(Γ, T RJnode(S, por, leaf(True, E, [])), P Tm K) −−−−−−−−−−−−→ (Γ0 , T RJleaf(True, E, S)K)
falls nfΦ (Γ, P Tm ) = Γ0
choose−por−true−l
(Γ, T RJnode(S, amb, P Tn , leaf(v, []))K)
−−−−−−−−−→
(Γ0 , T RJleaf(v, E, S)K)
falls v ein Wert ist und nfΦ (Γ, P Tn ) = Γ0
choose−amb−r
(Γ, T RJnode(S, amb, leaf(v, E, [])), P Tm K)
−−−−−−−−−→
(Γ0 , T RJleaf(v, E, S)K)
falls v ein Wert ist und nfΦ (Γ, P Tm ) = Γ0
(Γ, T RJleaf(por s t, E, S)K)
(Γ, T RJleaf(amb s t, E, S)K)
(Γ, T RJleaf(e, E, S)K)
−
→
(Γ0 , T RJleaf(e0 , E 0 , S 0 )K)
a
falls (Γ, e, E, S) −
→ (Γ0 , e0 , E 0 , S 0 )
mit einer Transition der Mark 2 (definiert in Abbildung 4.2)
Wir erläutern die Regeln trotzdem schonmal. Die erste Regel zieht die deterministischen Transitionen der Mark 2-Maschine auf einzelne Blätter der Concurrent Mark 2 hoch. Die Transitionen
f ork−amb
f ork−por
−−−−−−→ und −−−−−−→ erzeugen für einen amb- bzw. por-Ausdruck zwei Threads zur Auswerchoose−amb−l
tung der Argumente, der momentane Stack wird im Knoten gespeichert. Die Regeln −−−−−−−−−→
choose−amb−r
und −−−−−−−−−→ können angewendet werden, wenn eine Auswertung eines amb-Arguments erfolgreich war. Der erhaltene Wert wird als Gesamtwert des amb-Ausdrucks übernommen. Allerdings reicht das nicht aus, denn die Auswertung zugehörig zum zweiten Argument kann noch
Heapeinträge belegen, die noch zurück geschrieben werden müssen. Hierfür ist die Funktion nfΦ
vorgesehen – sie bereinigt den Heap und wird später erläutert (Abschnitt 4.3.3).
choose−por−true−l
choose−por−true−r
choose−por−f alse
Die Regeln −−−−−−−−−−−−→, −−−−−−−−−−−−→, und −−−−−−−−−−−→ implementieren das
Zurückkehren von einer por-Auswertung: Wurde ein Argument erfolgreich zu True ausgewertet,
so darf True als Gesamtwert übernommen werden (auch hier ist eine Heapanpassung mittels nfΦ
nötig). Der Wert False darf nur als Gesamtwert genommen werden, wenn beide Argumente zu
False ausgewertet wurden.
blackhole4
blackhole5
Die Regeln −−−−−−→ und −−−−−−→ decken zum einen die Fälle ab, dass ein por-Argument zwar
zu einem Wert ausgewertet wurde, der jedoch nicht boolesch ist. Zum anderen decken sie den
Fall ab, dass ein Argument zu False ausgewertet wurde, das andere aber seine Auswertung noch
nicht beendet hat.
4.3.2
Faire Thread-Auswahl
Bisher wurden die Ressourcen an den Knoten des Prozessbaums ignoriert. Die Regeln in Abbildung 4.3 machen davon keinen Gebrauch. Dies werden wir jetzt ändern, indem wir zunächst
festlegen, dass die Regeln nur angewendet werden dürfen, nachdem das Blatt, welches in der
choose−por−f alse
Regel verwendet wird, erfolgreich ausgewählt wurde. Für die Regel −−−−−−−−−−−→ bedeutet
das, dass sie eigentlich zwei Regeln beinhaltet: eine für das linke und eine für das rechte Blatt.
Nun definieren wir, wie Blätter unter Benutzung der Ressourcen fair ausgewählt werden. In
Abbildung 4.4 wird die (nichtdeterministische) Funktion select definiert. Diese wählt zum einen
ein Blatt aus dem Prozessbaum aus, zum anderen passt sie die Ressourcen an und führt ein
Scheduling durch, falls beide Ressourcen eines Knotens 0 sind. Deswegen erhält sie zwei Eingaben, zum einen einen Baumkontext, zum anderen einen Prozessbaum. Gestartet wird sie mit
dem leeren Baumkontext und dem eigentlichen Prozessbaum. Als Ergebnis liefert sie ein Paar
bestehend aus einem Baumkontext und einem Blatt. Seien T R und P T die Eingaben und sei
select(T R, P T ) = (T R0 , leaf(s, E, S)), dann sind T RJP T K und T R0 Jleaf(s, E, S)K gleiche Prozessbaum bis auf Ressourcen, diese wurden von select verändert.
Die erste Gleichung beschreibt gerade den Basisfall: Die select-Funktion ist an einem Blatt
angekommen und gibt dieses und den aktuellen Baumkontext zurück. Die nächsten Gleichungen
verarbeiten Baumknoten. Wenn für beide Teilbäume noch Ressourcen vorhanden sind, dann
wählt select nicht-deterministisch einen der beiden Knoten aus, und verringert die entsprechende
Ressource. Die dritte und vierte Gleichung sind ähnlich, allerdings deterministisch, da eine der
beiden Ressourcen 0 ist. Die letzte Gleichung sichert den Fall ab, dass beide Ressourcen 0
sind. Dann werden beide Ressourcen nichtdeterministisch hoch gesetzt. Hierbei ist wichtig, dass
wirklich beide Ressourcen einen Wert größer als 0 erhalten.
42
Abbildung 4.4: Regeln zur Thread-Auswahl in der Concurrent-Mark 2 Maschine
43
select(T RJ¦K, node(S, ndV al, 0, 0, P T1 , P T2 )) = select(T RJ¦K, node(S, ndV al, n, m, P T1 , P T2 ))
wobei n und m beliebige natürliche Zahlen größer 0 sind
select(T RJ¦K, node(S, ndV al, 0, m, P T1 , P T2 )) = select(T RJnode(S, ndV al, 0, m − 1, P T1 , J¦K)K, P T2 )
falls m > 0
select(T RJ¦K, node(S, ndV al, n, 0, P T1 , P T2 )) = select(T RJnode(S, ndV al, n − 1, 0, J¦K, P T2 )K, P T1 )
falls n > 0
select(T RJ¦K, leaf(s, E, S)) = (T RJ¦K, leaf(s, E, S))

 select(T RJnode(S, ndV al, n − 1, m, J¦K, P T2 )K, P T1 )
select(T RJ¦K, node(S, ndV al, n, m, P T1 , P T2 )) =
oder

select(T RJnode(S, ndV al, n, m − 1, P T1 , J¦K)K, P T1 )
falls m > 0 und n > 0
Das durch select berechnete Blatt steuert die Auswertung, indem es zur Anwendung der Transitionsregel aus Abbildung 4.3 verwendet wird. Bei einigen Regeln ist jedoch auch der direkte
Vater und der Geschwisterknoten von Belang.
In der folgenden Aufgabe wird nun die Funktion nextState zum Berechnen eines Nachfolgezustands implementiert, d.h. es soll ein Transitionsschritt auf der Concurrent Mark 2-Maschine
durchgeführt werden. Auch wenn die formale Darstellung in dieser Anleitung zweischrittig ist, indem sie die Blattselektion durch select und die Transition trennt, ist es für die Implementierung
ratsam, beide Schritte in einer Funktion durchzuführen. Der Hauptgrund ist, dass ansonsten
mit Baumkontexten gerechnet werden muss, und unnötig viel im Prozessbaum gesucht werden
muss.
Desweiteren ist zu beachten, dass wir die Funktion nfΦ bisher nicht spezifiziert haben, dass
verschieben wir auf den nächsten Abschnitt. Für die Implementierung von nextState gehen wir
davon aus, dass eine solche Funktion existiert:
> nfPhi :: CMark2Heap -> CMark2PTree -> Counter -> (CMark2Heap,Counter)
Sie erwartet einen Heap, einen Prozessbaum und den Counter zum Erzeugen von Heapvariablen
und liefert neben dem neuen Heap noch einen neuen Counter zurück, d.h. sie kann auch diesen
Zähler modifizieren.
Zum Testen können wir zunächst als (falsche) Implementierung für nfPhi verwenden:
> nfPhi hp pt counter = (hp,counter)
44
Aufgabe 16. Implementieren Sie im Modul LFPC.AbsM.ConcurrentMark2
• die Funktion nextState mit der Signatur:
> nextState :: CMark2State
>
-> RandomList
>
-> Counter
>
-> (CMark2State,RandomList,Counter)
Die Funktion erwartet einen Zustand der Concurrent Mark 2-Maschine, eine Liste
von Zufallszahlen und einen Counter. Als Ergebnis liefert sie ein 3-Tupel bestehend
aus dem Nachfolgezustand, den verbleibenden Zufallszahlen und dem modifizierten
Counter.
Der Typ RandomList ist im Modul LFPC.Util.Declarations gerade definiert als
type RandomList = [Integer]. Sie benötigen die Zufallszahlen zum einen zum
Auswählen eines linken oder rechten Teilbaums (z.B. wenn die erste Zahl ungerade
ist, dann links, sonst rechts) und für das Scheduling, zum Erhöhen der Ressourcen.
• die Funktion
> finalState :: CMark2State
-> RandomList
-> Counter
-> (CMark2State, RandomList, Counter)
die einen Zustand, eine Liste von Zufallszahlen und einen Counter erwartet, den
Endzustand der Maschine, den neuen Counter und die nicht benutzten Zufallszahlen zurückgibt.
• die Funktion exec :: CoreLFPCR -> RandomList -> Counter -> String, die
einen CoreLFPCR-Ausdruck, eine Liste von Zufallszahlen und einen Counter
erhält und zunächst den Startzustand, anschließend den Endzustand der Concurrent Mark 2-Maschine berechnet und das Ergebnis als String zurück gibt. Wie vorher sollten Zahlen in Dezimaldarstellung zurück konvertiert werden. Hierbei ist
darauf zu achten, dass erneute Berechnungen zum Ausdrucken Bindungen im Heap
nur einmal und nicht mehrfach auswerten. Dies ist notwendig, um das richtige Verhalten des call-by-need Nichtdeterminismus zu implementiern.
4.3.3
Sicheres Beenden von Threads
In diesem Abschnitt definieren wir die Funktion nfΦ . Vorher gehen wir aber noch auf die Problematik ein, warum diese benötigt wird. Wir betrachten als Beispiel die Auswertung des Ausdrucks letrec x = (λy.y) in (amb (λz.z) x) x und nehmen zunächst an, dass nfΦ den Heap nicht
verändert, d.h. der nicht benötigte Thread einer amb-Auswertung wird einfach weggeworfen. Wir
lassen die Ressourcen, ndV al und die Umgebung (wir benutzen die Variablen direkt wie bei der
45
Mark 1-Maschine) in der Darstellung zur besseren Repräsentation weg.
(∅, leaf(letrec x = (λy.y) in (amb (λz.z) x) x, []))
mkBinds
−−−−−−→ ({x 7→ (λy.y)}, leaf((amb (λz.z) x) x, []))
push
−−−→ ({x 7→ (λy.y)}, leaf(amb (λz.z) x, [#app (x)]))

[#app (x)]
KK

q
q
KK
q
f ork−amb 
q
KK
q
q
−−−−−−→ {x 7→ (λy.y)},
KK
q
q
K%
q

xq
(λz.z, [])
(x, [])


enter 
−−−→ ∅,

QQQ
QQQ
QQQ
QQQ
Q(
(λz.z, [])





[#app (x)]
r
rrr
r
r
r
rx rr

((λy.y), [#heap (x)])




choose−amb−l
−−−−−−−−−→ (∅, leaf(λz.z, [#app (x)]))
take
−−→ (∅, leaf(x, []))
Die Maschine ist nun in einem Zustand, in dem die Variable x frei ist (weil sie nicht im Heap
gebunden ist), obwohl der auszuwertende Ausdruck geschlossen war.
choose−amb−l
Der Fehler besteht darin, dass beim Schritt −−−−−−−−−→ der rechte Thread einfach weggeworfen
wird, obwohl er die Variable x aus dem Heap noch bearbeitet.
Das folgende “Ersetzungssystem” wird nun benutzt, um den Heap bezüglich eines Prozessbaums
zu bereinigen. Es reicht nicht aus lediglich die #heap -Markierungen auf dem Stack zu suchen,
sondern sämtliche Schritte die Einträge auf den Stacks erzeugen müssen rückgängig gemacht
werden.
Die Regeln des Ersetzungssystem sind in Abbildung 4.5 definiert.
push
Hier zeigt sich eine weitere Schwierigkeit. Dummerweise kann die Operation −−−→, die das Argument einer Applikation auf den Stack schiebt, nicht ohne weiteres rückgängig gemacht werden,
da wir nicht wissen, welche Variable in der Umgebung auf den auf dem Stack liegenden Pointer
zeigt. Z.B. kann ein Thread die Form (e, {x 7→ p, y 7→ p}, #app (p) : S) haben. Wir wissen schonmal nicht, ob dieser Zustand aus (e x) oder (e y) entstanden ist. Wenn man genau überlegt,
könnte man wahllos x oder y nehmen, da dies aufgrund des Sharings keinen Unterschied machen sollte. Allerdings ist die Suche in der Umgebung sehr aufwändig (Wir suchen ja nicht nach
Schlüsseln!). Effizienter ist es, einfach eine neue Variable zu benutzen und diese in der Umgebung
auf p abzubilden. Den neuen Variablennamen, können wir ja mithilfe des Counters generieren,
z.B. wenn der Counter gerade 1000 ist, dann generieren wir den Namen _var_nfPsi_1000.
Wichtig ist, das die internen Namen unterschiedlich sind, von denen die wir beim Umbenennen
benutzt haben.
enter
Beim Rückgängigmachen von −−−→ haben wir das Problem, dass wir zwar den Heap richtig
update
updaten können, wir aber nicht – wie bei −−−−→ – den Ausdruck beibehalten können, da es
46
Abbildung 4.5: Die Regeln Φ zum Abrollen der Stacks
47
Φ
6
(Γ, T RJnode(S, por, m, n, leaf(tl , El , []), leaf(tl , El , []))K) −→
(Γ ∪ {p 7→ (tr , Er )}, T RJleaf(por tl x, El ∪ {x 7→ p}, S)K)
wobei p neue Heapvariable und x neue Variable
Φ
5
(Γ, T RJnode(S, amb, m, n, leaf(tl , El , []), leaf(tr , Er , []))K) −→
(Γ ∪ {p 7→ (tr , Er )}, T RJleaf(amb tl x, El ∪ {x 7→ p}, S)K)
wobei p neue Heapvariable und x neue Variable
Φ
4
(Γ, T RJleaf(s, E1 , #seq (t, E2 ) : S)K) −→
(Γ ∪ {p 7→ (s, E1 )}, T RJleaf(seq x t, E2 ∪ {x 7→ p}, S)K)
wobei p neue Heapvariable und x neue Variable
Φ
Φ
3
(Γ, T RJleaf(s, E1 , #case (alts, E2 ) : S)K) −→
(Γ ∪ {p 7→ (s, E1 )}, T RJleaf(case x alts, E2 ∪ {x 7→ p}, S)K)
wobei p neue Heapvariable und x neue Variable
2
(Γ, T RJleaf(s, E, #heap (p) : S)K) −→
(Γ{p 7→ (s, E)}, T RJleaf(x, {x 7→ p}, S)K)
wobei x neue Variable
Φ
1
(Γ, T RJleaf(s, E, #app (p) : S)K) −→
(Γ, T RJleaf(s x, E ∪ {x 7→ p}, S)K)
wobei x neue Variable
nicht notwendigerweise ein Wert ist. Deshalb führen wir eine neue Programmvariable ein, die
auf die Heapvariable zeigt (das geschieht durch Erzeugen der neuen Umgebung mit dieser einen
Bindung.)
pushAlts
Beim Rückgängigmachen von −−−−−→ verschärft sich das Problem noch, da wir zwei verschiedene Umgebungen haben: Einmal die Umgebung, die mit den Alternativen auf den Stack geschoben
wurde, und die Umgebung des aktuell auszuwertenden Ausdrucks. Um diese Situation korrekt zu
bereinigen, schieben wir den aktuell auszuwertenden Ausdruck mit seiner Umgebung als neue
Heapbindung in den Heap, und bauen einen case-Ausdruck, dessen erstes Argument gerade
(über die veränderte Umgebung) auf den neuen Heapeintrag zeigt. Um nichts falsch zu machen,
muss hier wieder ein neuer Variablennamen in die Umgebung eingefügt werden. Im Unterschied
zu vorherigen Fall muss hier auch eine neue Heapvariable erzeugt werden.
Stellt man das ganze in LFP+C dar (wobei der Heap einfach ein äußeres großes letrec sei),
entspricht das in etwa der Transformation
letrec x1 = s1 , . . . xn = sn in (case e of alts)
→ letrec x1 = s1 , . . . xn = sn , xn+1 = e in (case xn+1 of alts)
pushSeq
Das gleiche Problem tritt auf beim Rückgängigmachen einer −−−−−→-Transition. Auch hier
haben wir zwei Umgebungen und erzeugen deswegen eine neue Variable für das erste Argument
des seq und schieben den eigentlichen Ausdruck samt seiner Umgebung auf den Heap.
Wenn zwei Threads aus einem amb oder por entstanden sind, müssen diese nach dem Abrollen
wieder vereint werden. Jedoch tritt hier erneut ein Problem auf: Beim Vereinen gibt es zwei
Ausdrücke (den linken und den rechten des amb bzw. por) mit zwei Umgebungen. Wir verwenden hier einen ähnlichen Trick wie vorher: Wir schieben das rechte Argument mitsamt seiner
Umgebung in den Heap, und fügen in der Umgebung des linken Ausdrucks eine entsprechende
Substitution ein. Vereinfacht dargestellt entspricht das der Transformation
amb e1 e2 → letrec x1 = e2 in amb e1 x1
bzw.
por e1 e2 → letrec x1 = e2 in por e1 x1
Man beachte, dass man eigentlich die Korrektheit einer solchen Transformation zeigen müsste,
um zu garantieren, dass der Interpreter richtig auswertet.
Es lässt sich leicht zeigen, dass das Ersetzungssystem für erreichbare Prozessbäume lokal konfluent ist, d.h. das folgende Diagramm gilt stets:
·=
===Φj
==
=
·=
·
=
=
Φj
= Φi
Φi
·
für 1 ≤ i, j ≤ 6.
48
Da das System für erreichbare Prozessbäume auch terminierend ist, ist es konvergent. Das impliziert das eindeutige Normalformen bezüglich des Systems existieren. Normalformen sind solche
Paare bestehend aus einem Heap und einem Prozessbaum, so dass keine Regel mehr anwendbar
ist. Man überlegt sich leicht, dass diese (für erreichbare Prozessbäume) alle aus einem einzigen
Blatt bestehen, dessen Stack leer ist. Deshalb lässt sich die Funktion nfΦ als Normalform des
Ersetzungssystems definieren (der Einfachheit lassen wir den Prozessbaum im Ergebnis gleich
weg, da uns nur der Heap interessiert).
Aufgabe 17. Implementieren Sie das korrekte Beenden der Threads im Modul
LFPC.AbsM.ConcurrentMark2, indem Sie die Funktion nfPhi implementieren. Hierfür
müssen Sie aus dem Ersetzungssystem einen rekursiven Algorithmus konstruieren, der
die Normalform berechnet.
Testen Sie anschließend Ihre Maschine mit nichtdeterministischen Operatoren.
Im Modul LFPC.Run steht die Funktion randsZ zur Verfügung, die eine Int-Zahl erwartet und
eine Liste von positiven Zufallszahlen erzeugt.
Aufgabe
18. Implementieren Sie im Modul LFPC.Run
runCMark2 :: Int -> String -> String die eine Zahl erwartet
ren des Zufallsgenerators) und ein LFP+C -Programm und dieses lext,
analysiert und auf der Concurrent Mark 2-Maschine ausführt und
Ausführung als String zurückgibt.
4.4
eine Funktion
(zum Initialisieparst, semantisch
das Ergebnis der
Die Virtuelle Maschine, der Compiler und der Interpreter
Wir sind nun fast fertig, wir können LFP+C -Programme ausführen. Allerdings können wir die
nach CoreLFPCR transformierten Programme schlecht abspeichern, denn sie liegen in einer Baumstruktur vor, d.h. wir müssten sie beim Ausführen erst erneut parsen. In diesem Abschnitt werden
wir das ändern.
4.4.1
Codegenerierung und Codeloader der VM
Aufgabe 19. Überlegen Sie sich eine einfache Repräsentation der Sprache CoreLFPCR
als String und implementieren Sie im Modul LFPC.AbsM.CodeGen die Funktionen
• langToAscii :: CoreLFPCR -> String, die einen Ausdruck in einen String konvertiert.
• asciiToLang :: String -> CoreLFPCR, die einen String in einen CoreLFPCRAusdruck konvertiert.
Das wichtigste Kriterium beim Entwurf der Kodierung sollte sein, dass asciiToLang
nur lineare Zeit in der Länge des Strings verbraucht.
49
4.4.2
lfpcc – Der Compiler für LFP+C
Aufgabe 20. Implementieren Sie die Funktion main im Modul LFPC.Compiler.Main,
die den durch den Aufruf des Compilers übergebenen Dateinamen liest, und den Inhalt
lext, parst, semantisch analysiert, in CoreLFPCR-konvertiert und schließlich in StringRepräsentation in eine Datei schreibt.
Hierbei sollten entsprechende Optionen zur Angabe des Output-Dateinamens und des
Input-Dateinamens vorhanden sein. Verwenden Sie dazu die Funktionen aus den Bibliotheken System.Environment und System.Console.GetOpt
Compilieren Sie den Compiler mit den GHC.
Hinweis: Da der echte Name des Moduls Main ist, sollten Sie die Option -cpp beim
Aufruf des GHC verwenden.
4.4.3
lfpcvm – Die Virtuelle Maschine
Aufgabe 21. Implementieren Sie die Funktion main im Modul LFPC.VM.Main, die innerhalb den dem Programm übergebenen Argumenten einen Dateinamen erwartet, diese
Datei liest und den daraus erhalten ASCII-Code auf der Concurrent Mark 2 Maschine
ausführt. Beim Erzeugen der Zufallszahlen benötigen Sie ein zufällige Int-Zahl. Hierfür
können Sie die Funktion randomIO aus der Bibliothek System.Random verwenden.
Compilieren Sie die Virtuelle Maschine.
4.4.4
lfpci – Ein Interpreter für LFP+C
Aufgabe 22. Benutzen Sie die Bibliothek System.Console.SimpleLineEditor um im
Modul LFPC.Interpreter.Main einen interaktiven Interpreter für LFP+C zu implementieren. Im Interpreter sollen LFP+C Ausdrücke ausgewertet werden können, und zwar
beliebig oft, wobei jeweils neue Zufallszahlen erzeugt werden sollten. Desweiteren sollte
der Interpreter wie ghci mittel :q verlassen werden können.
Ein Parse-Fehler sollte nicht gleich zum Beenden des Interpreters führen. Dies sollte
abgefangen werden, hierfür könnten die Funktionen try und evaluate aus der Bibliothek
Control.Exception hilfreich sein.
Compilieren Sie den Interpreter.
4.5
Automatische Speicherverwaltung
Unsere Maschine hat noch das Problem, dass gar nicht mehr erreichbare Heap-Bindungen trotzdem nicht entfernt werden. Deswegen wird wesentlich mehr Platz verbraucht als eigentlich notwendig.
50
Aufgabe 23. Implementieren Sie eine Funktion
gc :: ConcurrentMark2State -> ConcurrentMark2State
die einen Zustand der Concurrent Mark 2-Maschine erwartet und einen neuen Heap
erstellt, indem nur noch die benötigten Heapbindungen vorhanden sind, d.h. es müssen
alle Umgebungen nach referenzierten Bindungen durchsucht werden,
Erweitern Sie anschließend die Virtuelle Maschine und den Interpreter, so dass er
ab einer bestimmten Heapgröße eine Garbage Collection (d.h. eine Heapbereinigung)
durchführt.
51
Kapitel 5
Hinweise zu einzelnen Themen
5.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 Änderen nachzuziehen, d.h. die beiden Dateien zu “mergen”. Kommt es hierbei zu
Konflikten, so muss der Benutzer diese per Hand beheben.
5.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.
5.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 User-Namen und das auzucheckende Modul
spezifiert 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.
52
5.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
5.1.4
Dateien einchecken
Um die eigenen Änderungen auf den CVS-Server hochzuladen, ist das Kommand 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 Dateinnamen 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 CVS-Server.
5.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
5.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.
53
5.1.7
Graphische Oberflächen für CVS
Neben den hier vorgestellen 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/)
5.2
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
>
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:
54
> data Student = Student {
>
matrikelnummer
>
vorname
>
name
>
studiengang
>
fachsemester
>
}
::
::
::
::
::
Int,
String,
String,
String,
Int
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
55
5.3
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
> | 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
5.4
Modularisierung in Haskell
Dieser Abschnitt ist entnommen aus [12].
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.
56
Wiederverwendbarkeit: Ein Modul kann für verschiedene Programme benutzt (d.h. importiert) werden.
5.4.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,

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 9.
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
57
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 9 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 9 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 9 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.
– 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 9 definiert und das Modul Game als:
58
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
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.
59
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
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:
60
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ü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.
5.5
Parser und Parsergeneratoren
In der reinen Haskell-Aufgabe (Beweisen mit Hilfe von Wahrheitstabellen) soll der Parsergenerator Happy3 für das Parsen des Eingabestrings 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.
5.5.1
Parser und Syntaxanalyse
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
1
An
der
Standardisierung
der
hierarchischen
Modulstruktur
http://www.haskell.org/hierarchical-modules
2
siehe http://www.haskell.org/ghc/docs/latest/html/libraries
3
Verfügbar unter http://haskell.org/happy
61
wird
gearbeitet,
siehe
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 werden können. Ein solches Beispiel ist eben gerade die Überprüfung eines aussagenlogischen Ausdrucks. Ein anderes Beispiel wird im nächsten
Unterabschnitt vorgestellt.
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:
Expr ::= Zahl
|
( Expr )
|
Expr + Expr
|
Expr − Expr
|
Expr ∗ Expr
|
Expr / Expr
Zahl ::= Ziff | Zahl Ziff
Ziff ::= 1 | 2 | ... | 9 | 0
62
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.
5.5.2
Parsergeneratoren
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 BNF4 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,5 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
5
bzw. einer der BNF ähnlichen Notation, wie im Beispiel des Happy
steht für Yet another Compiler Compiler
63
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 Haskell-Text:
> 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’).6 Man nennt 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:7
[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 Alex8 ist wie bereits erwähnt ein Lexer-Generator. Da man aber den ASCIIStrom häufig recht einfach von Hand“ in den zugehörigen Tokenstrom überführen kann, wird
”
ein solches Programm seltener benutzt als ein Parsergenerator.
5.5.3
Happy
Happy9 ist ein Parsergenerator, dessen Zielsprache Haskell ist. Er hat viel von Yacc übernommen.
Die offizielle Referenz ist [6].
6
Diese Zeichen werden auch als Whitespace“ bezeichnet.
”
wobei wir gleich eine Schreibweise wählen, wie wir sie auch in Haskell benutzen würden, vorausgesetzt ein
Datentyp Token existierte.
8
http://haskell.org/alex/
9
http://haskell.org/happy/
7
64
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).
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 }
%token
65
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 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 ParserFunktion 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.
66
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]
lexer [] = []
lexer (’+’:cs) = TokenPlus : lexer cs
lexer (’-’:cs) = TokenMinus : lexer cs
lexer (’*’:cs) = TokenTimes : lexer cs
lexer (’/’:cs) = TokenDiv : lexer cs
lexer (’(’:cs) = TokenOB : lexer cs
lexer (’)’:cs) = TokenCB : lexer cs
lexer (c:cs)
| isSpace c = lexer cs
| isDigit c = lexNum (c:cs)
| otherwise = 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
67
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)
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“ be”
”
deutet. 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.).
5.6
Haddock – A Haskell Documentation Tool
Haddock (http://haskell.org/haddock) dient zum Erstellen einer HTML-Dokumentation 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.
5.6.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
68
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.
Man beachte, dass Haddock mit sämtlichen Quelldateien aufgerufen werden muss, um eine korrekte Verlinkung der Dokumente zu erhalten. Deshalb ist in der Datei genDoc.sh ist ein BashSkript zu finden (Für MS Windows findet sich dort noch eine äquivalente Batch-Datei namens
genDoc.bat.), der den Aufruf zum Erstellen der Dokumentation automatisiert. Hierbei ist der
Haddock-Aufruf haddock ... --html -o ./doc $SRCFILES, wobei --html bedeutet, dass
eine HTML-Dokumentation generiert wird und mit .o ./doc die HTML-Dateien im Verzeichnis doc/ abgelegt werden (dieses muss vorher vorhanden sein!).
Wir
benutzen
zusätzlich
den
ghc-Präprozessor
(die
Optionen
--optghc=-cpp --optghc=-optP-P --optghc=-D__HADDOCK__):
In
den
Modulen
LFPC.Compiler.Main, LFPC.Interpreter.Main und LFPC.VM.Main gaukeln wir Haddock
genau diese Modulnamen vor, um sie in der Struktur korrekt einzuordnen. Z.B. im Modul
LFPC.VM.Main sieht der Modulkopf wie folgt aus:
#ifdef __HADDOCK__
> module LFPC.VM.Main(main) where
#else
> module Main(main) where
#endif
Da ausführbare Module aber den Namen Main haben müssen, ist der echte Name nur Main.
Deshalb müssen wir beim Compilieren mit dem ghc die Option -cpp angeben um dort den
Namen richtig zu setzen.
69
Literaturverzeichnis
[1] Richard S. Bird. Introduction to Functional Programming Using Haskell. Prentice-Hall,
1998.
[2] Manuel M. T. Chakravarty and Gabriele C. Keller. Einführung in die Programmierung mit
Haskell. Pearson Studium, 2004.
[3] Paul Hudak, John Peterson, and Joseph H. Fasel. A gentle introduction to haskell, 2000.
online verfügbar unter http://haskell.org/tutorial/.
[4] John Hughes and Andrew Moran. Making choices lazily. In FPCA ’95: Proceedings of
the seventh international conference on Functional programming languages and computer
architecture, pages 108–119, New York, NY, USA, 1995. ACM Press.
[5] Simon Peyton Jones, editor. Haskell 98 Language and Libraries. Cambridge University
Press, April 2003. auch online verfügbar unter http://haskell.org/definition.
[6] Simon Marlow and Andy Gill. Happy User Guide, 1997-2001. online verfügbar über
http://haskell.org/happy/doc/html/index.html.
[7] John McCarthy. A Basis for a Mathematical Theory of Computation. In P. Braffort and
D. Hirschberg, editors, Computer Programming and Formal Systems, pages 33–70. NorthHolland, Amsterdam, 1963.
[8] A. K. Moran. Call-by-name, Call-by-need, and McCarthy’s Amb. PhD thesis, Department
of Computing Science, Chalmers University of Technology and University of Gothenburg,
Gothenburg, Sweden, September 1998.
[9] Bryan O’Sullivan, Donald Stewart, and John Goerzen. Real World Haskell. O’Reilly Media,
Inc., December 2008. Webseite zum Buch: http://book.realworldhaskell.org/read/.
[10] David Sabel. Semantics of a Call-by-Need Lambda Calculus with McCarthy’s amb for
Program Equivalence. Dissertation, J. W. Goethe-Universität Frankfurt, Institut für Informatik. Fachbereich Informatik und Mathematik, November 2008. Verfügbar unter
http://www.ki.informatik.uni-frankfurt.de/papers/sabel/dissertation-sabel.pdf.
[11] David Sabel and Manfred Schmidt-Schauß. A call-by-need lambda-calculus with locally
bottom-avoiding choice: Context lemma and correctness of transformations. Mathematical
Structures in Computer Science, 18(03):501–553, 2008.
[12] Manfred Schmidt-Schauß. Skript zur Vorlesung Grundlagen der Programmierung 2“.
”
http://www.informatik.uni-frankfurt.de/∼prg2/SS2008/index.html, (Sommersemester 2008).
Kapitel 1 + 2.
70
[13] Manfred Schmidt-Schauß. Skript zur Vorlesung Einführung in die Funktionale Program”
mierung“. http://www.ki.informatik.uni-frankfurt.de/lehre/WS2008/EFP/, (Wintersemester
2008 / 2009).
[14] Manfred Schmidt-Schauß and David Sabel. Program transformation for functional circuit
descriptions. In Workshop on Hardware design and Functional Languages 2007, Braga,
Portugal, March 2007, 2007.
[15] P. Sestoft. Deriving a lazy abstract machine. J. Funct. Programming, 7(3):231–264, 1997.
[16] H. Søndergaard and P. Sestoft. Non-determinism in functional languages. Comput. J.,
35(5):514–523, 1992.
[17] Simon Thompson. Haskell – The Craft of Functional Programming. Addison-Wesley, 1999.
71
Herunterladen