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