Technische Universität München Institut für Informatik Prof. Tobias Nipkow, Ph.D. N. Schirmer, M. Blume WS 2002/2003 Dokumentation 26. November 2002 Funktionales Programmieren und Compilerbau Dokumentation und Überblick für Teil 2 des Programmirpraktikums Inhaltsverzeichnis 1 Aufgabenstellung 1 2 Beschreibung der Quellsprache (SimpleJava) 2 2.1 Konkrete Syntax . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2 2.2 Abstrakte Syntax . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2 2.3 Beispiel . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3 3 Die PMI-Maschine 3 3.1 Aufbau . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3 3.2 Der Speicher . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4 3.3 Adressierungsarten . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4 3.4 Befehlssatz . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5 3.5 Zu implementierende Funktionen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 8 4 Der Compiler 4.1 1 8 Hilfsmodule . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 8 4.1.1 Der Datentyp Error . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 8 4.1.2 Parserkombinatoren . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 9 4.2 Beispielprogramm . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 12 4.3 Lexikalische Analyse (Scanner) . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 13 4.4 Syntaktische Analyse (Parser) . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 13 4.5 Semantische Analyse . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 14 4.6 Codegenerator . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 14 4.7 Test mit der PMI . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 16 Aufgabenstellung Ziel des 2. Teils des Praktikums ist es, einen Compiler für die Programmiersprache SimpleJava zu schreiben und das erzeugte Maschinenprogramm auf einer virtuellen Maschine auszuführen. In Abschnitt 2 wird die Syntax der Sprache SimpleJava beschrieben, eine selbstdefinierte einfache Form von Java. Sie verwendet im wesentlichen diesselbe Syntax wie Java, ist aber vom Umfang her sehr beschränkt (z.B. keine Klassen, einziger Datentyp int). Als virtuelle Maschine wird die PMI-Maschine gewählt, bekannt aus der Informatik II Vorlesung bei Prof. Brügge. Abschnitt 3 enthält eine Erläuterung, wie die PMI-Maschine in Haskell umgesetzt ist. In Abschnitt 4 werden die Schnittstellen der einzelnen Bestandteile des Compilers definiert und anhand eines Beispiels vorgestellt. 1 Die Implementation erfolgt in vier Teilaufgaben: • PMI Maschine • Code Generierung • Lexer und Parser • Analyse Für die Teilaufgaben werden jeweils noch gesonderte Übungsblätter ausgegeben. Bei den Aufgaben sind dann sowohl die gegebenen Schnittstellen als auch benötigte Hilfsfunktionen zu implementieren. 2 Beschreibung der Quellsprache (SimpleJava) 2.1 Konkrete Syntax Ein SimpleJava Programm muss den nachfolgenden syntaktischen Regeln entsprechen. <program> <separator> ::= ::= <declaration>∗ <statement>∗ ; <declaration> ::= int t <variable> [ , <variable> ]∗ <separator> <statement> <assignment> <if-clause> <while-clause> <sequence> ::= ::= ::= ::= ::= <assignment> | <if-clause> | <while-clause> | <sequence> <variable> = <expression> <separator> if (<condition>) <statement> [ else <statement> ]? while (<condition>) <statement> { <statement>∗ } <expression> <condition> ::= ::= <variable> | <number> | (<expression> <arithOp> <expression>) <expression> <relOp> <expression> <arithOp> <relOp> ::= ::= +|−|*|/ == | != | > | >= | < | <= <variable> <number> ::= ::= <letter> [ <letter> | <digit> | <digit>+ | (− <digit>+ ) <letter> <digit> ::= ::= a | b | ... | z | A | ... | Z 0 | 1 | ... | 9 2.2 ]∗ Abstrakte Syntax Um das SimpleJava Programm compilieren zu können, muss die konkrete Syntax in eine abstrakte Form gebracht werden. Die abstrakte Syntax ist in Haskell so festgelegt (siehe auch die Dateien SJAbstractSyntax.hs und SJOperators.hs): data Program = Program Declarations [Statement] deriving (Show, Read) type Declarations = [Identifier] data Statement = Assignment Identifier Expression | If Condition Statement Statement 2 | While Condition Statement | Sequence [Statement] deriving (Show, Read) data Expression = Variable Identifier | Number Integer | Compound Expression ArithOp Expression deriving (Show, Read) data Condition = Condition Expression RelOp Expression deriving (Show, Read) type Identifier = String data ArithOp = Plus | Minus | Times | Divide deriving (Eq, Show, Read) data RelOp = Equal | NotEqual | LessThan | LessOrEqual | GreaterThan | GreaterOrEqual deriving (Eq, Show, Read) 2.3 Beispiel Der Zusammenhang zwischen abstrakter und konkreter Syntax wird nun noch anhand eines Beispiels verdeutlicht. Eine mögliche Codezeile in einem SimpleJava Programm ist: if (a < 5) a = (a + 1); else a = 0; Die abstrakte Syntax sieht dann so aus: If (Condition (Variable "a") LessThan (Number 5)) (Assignment "a" (Compound (Variable "a") Plus (Number 1))) (Assignment "a" (Number 0)) 3 3.1 Die PMI-Maschine Aufbau Die PMI-Maschine besteht aus drei Registern (program counter, stack pointer, heap pointer), einem Statuswort und einem linearen Speicher. Zusätzlich können Größe und Lage von Stack und Heap im Speicher festgelegt werden. Das Statuswort enthält neben den bekannten Flags halted, negative und zero ein weiteres Flag err, das die Fehlermeldung enthält, wenn die Maschine aufgrund eines Fehlers angehalten wurde. Die Datentyp-Definitionen sehen folgendermaßen aus: data Machine = Machine {register :: Register, status :: Status, store :: Store, properties :: StoreProperties} deriving (Eq, Show, Read) 3 data Register = Register {pc :: StoreAddress, sp :: StoreAddress, hp :: StoreAddress} deriving (Eq, Show, Read) data Status = Status {halted :: Bool, negative :: Bool, zero :: Bool, err :: Error ()} deriving (Eq, Show, Read) Für den Datentyp Error siehe 4.1. 3.2 Der Speicher Der Speicher unserer Maschine weist einige Besonderheiten auf. Zunächst muss eine Speicherzelle nicht unbedingt existieren. Da die Maschine virtuell ist, sollen der Übersicht halber nicht alle Speicherzellen aufgeführt werden, sondern nur immer die gerade benutzten. Der Speicher wird mittels des abstrakten Datentyps Table realisiert und nur wenn eine Speicherzelle explizit in der Tabelle aufgeführt ist, ist sie auch vorhanden. Steht gerade kein definierter Wert in einer Zelle, so liefert ein Lesezugriff die ¨Zufallszahl¨ (-1234) zurück, da man in einer reellen Maschine ja auch nie wissen kann, was gerade im Speicher steht. In einer Speicherzelle kann wie gewohnt entweder eine Zahl oder ein Befehl stehen. Da Befehle aber nicht verschlüsselt werden, wird der Datentyp StoreValue eingeführt, der die Konstruktoren Value für Zahlen und Code für Maschineninstruktionen enthält. Dadurch kann es bei Leseaktionen im Speicher zu folgenden Problemen kommen: • Es wird ein Befehl erwartet, aber eine Zahl gefunden. Der PC zeigt also auf eine Speicherzelle, in der kein Befehl steht. In diesem Fall wird die Maschine mit einer Fehlermeldung angehalten. • Es wird eine Zahl erwartet, aber ein Befehl gefunden. In diesem Fall wird die Zahl (-1234) zurückgegeben. type Store = Table StoreAddress StoreValue type StoreAddress = Integer data StoreValue = Value Integer | Code MachineInstruction deriving (Eq, Show, Read) Der Datentyp MachineInstruction wird in 3.4 erläutert und der Datentyp Table ist in der Aufgabe vorgegeben. Mit den StoreProperties kann man Lage und Größe von Heap und Stack festlegen. Standardmäßig sind die Werte folgendermaßen initialisiert: heapStart = 8000 heapSize = 4000 stackStart = 16000 stackSize = 4000 Der Speicherbereich bis 8000 ist somit für den Code reserviert. data StoreProperties = StoreProperties {heapStart :: StoreAddress, heapSize :: Integer, stackStart :: StoreAddress, stackSize :: Integer} deriving (Eq, Show, Read) 3.3 Adressierungsarten Es werden die gleichen Adressierungsarten wie in der PMI unterstützt. Dafür werden folgende Datentypen definiert: 4 data MachineAddress = Immediate Address | Direct Address | Indirect Address deriving (Eq, Show, Read) data Address = Absolute StoreAddress | PC Offset | HP Offset | SP Offset deriving (Eq, Show, Read) type Offset = Integer Die folgende Tabelle zeigt, wie die Befehle des PMI-Assemblers in Haskell aussehen. unmittelbare Adressierung direkte Adressierung indirekte Adressierung 3.4 PMI-Assemblersprache push 1 push pc push hp+4 push sp-2 push @1 push @pc push @hp+4 push @sp-2 push >1 push >pc push >hp+4 push >sp-2 Haskell Push (Immediate (Absolute 1)) Push (Immediate (PC 0)) Push (Immediate (HP 4)) Push (Immediate (SP (-2))) Push (Direct (Absolute 1)) Push (Direct (PC 0)) Push (Direct (HP 4)) Push (Direct (SP (-2))) Push (Indirect (Absolute 1)) Push (Indirect (PC 0)) Push (Indirect (HP 4)) Push (Indirect (SP (-2))) Befehlssatz In Haskell sind die PMI Befehle durch den Datentyp MachineInstruction festgelegt. type MachineProgram = [MachineInstruction] data MachineInstruction = Halt | Push MachineAddress | Pop MachineAddress | Del | Add | Sub | Mult | Div | Comp | Jump MachineAddress | JmpN MachineAddress | JmpZ MachineAddress deriving (Eq, Show, Read) Die PMI-Befehle jsr und ret werden nicht implementiert, da Unterprogramme nicht zum Sprachumfang von SimpleJava gehören. Die Bedeutung der Befehle sind im einzelnen (dabei bedeutet @pre, dass die Werte vor der Ausführung des Befehls gemeint sind, also die Precondition-Werte): Halt Hält die Maschine an und beendet dadurch die Ausführung des Programms. 5 Postconditions: Status.halted = true pc = pc@pre Push value Legt den angegebenen Wert als oberstes Element auf dem Stapel ab. Preconditions: sp > StoreProperties.stackStart - StoreProperties.stackSize -- Stapel nicht voll Postconditions: sp = sp@pre - 1 pc = pc@pre + 1 Store.getValue(sp) = value Pop address Holt das oberste Element vom Stapel und legt es bei der angegebenen Adresse ab. Preconditions: sp < StoreProperties.stackStart -- Stapel ist nicht leer Postconditions: sp = sp@pre + 1 pc = pc@pre + 1 Store.getValue(address) = [email protected](sp@pre) Del Entfernt das oberste Element vom Stapel. Preconditions: sp < StoreProperties.stackStart -- Stapel ist nicht leer Postconditions: sp = sp@pre + 1 pc = pc@pre + 1 Add Holt die obersten zwei Elemente vom Stapel, addiert sie, und legt das Ergebnis als oberstes Element auf dem Stapel ab. Warnung: Ein eventuell stattfindender Überlauf wird nicht registriert! Preconditions: sp + 2 <= StoreProperties.stackStart -- Stapel enthaelt zwei Elemente Postconditions: sp = sp@pre + 1 pc = pc@pre + 1 Store.getValue(sp) = [email protected](sp@pre + 1) + [email protected](sp@pre) 6 Sub Holt die obersten zwei Elemente vom Stapel, subtrahiert sie, und legt das Ergebnis als oberstes Element auf dem Stapel ab. Warnung: Ein eventuell stattfindender Überlauf wird nicht registriert! Preconditions: sp + 2 <= StoreProperties.stackStart -- Stapel enthaelt zwei Elemente Postconditions: sp = sp@pre + 1 pc = pc@pre + 1 Store.getValue(sp) = [email protected](sp@pre + 1) - [email protected](sp@pre) Mult Holt die obersten zwei Elemente vom Stapel, multipliziert sie, und legt das Ergebnis als oberstes Element auf dem Stapel ab. Warnung: Ein eventuell stattfindender Überlauf wird nicht registriert! Preconditions: sp + 2 <= StoreProperties.stackStart -- Stapel enthaelt zwei Elemente Postconditions: sp = sp@pre + 1 pc = pc@pre + 1 Store.getValue(sp) = [email protected](sp@pre + 1) * [email protected](sp@pre) Div Holt die obersten zwei Elemente vom Stapel, dividiert sie (ganzzahlige Division), und legt das Ergebnis als oberstes Element auf dem Stapel ab. Warnung: Ein eventuell stattfindender Überlauf wird nicht registriert! Preconditions: sp + 2 <= StoreProperties.stackStart -- Stapel enthaelt zwei Elemente Postconditions: sp = sp@pre + 1 pc = pc@pre + 1 Store.getValue(sp) = [email protected](sp@pre + 1) / [email protected](sp@pre) Comp Vergleicht das oberste Element vom Stapel mit 0. Preconditions: sp < StoreProperties.stackStart -- Stapel ist nicht leer Postconditions: pc = pc@pre + 1 Status.zero = (Store.getValue(sp) == 0) 7 Status.negative = (Store.getValue(sp) < 0) Jump address Springt zu der angegebenen Adresse Postconditions: pc = address JmpN address Springt zu der angegebenen Adresse, wenn der letzte Vergleich mit compare() einen negativen Wert geliefert hat. Postconditions: Status.negative == true implies pc = address Status.negative == false implies pc = pc@pre + 1 JmpZ address Springt zu der angegebenen Adresse, wenn der letzte Vergleich mit compare() den Wert 0 geliefert hat. Postconditions: Status.zero == true implies pc = address Status.zero == false implies pc = pc + 1 3.5 Zu implementierende Funktionen Es wird eine Funktion run benötigt, die das gesamte Programm ausführt, das in der Maschine geladen ist. Die Funktion step führt nur einen Einzelschritt aus. Die Maschine wird durch ausführen des nächsten Befehls (adressiert durch pc) in den Folgezustand überführt. Die Schnittstellen sind wie folgt definiert: run :: Machine -> Machine step :: Machine -> Machine 4 4.1 4.1.1 Der Compiler Hilfsmodule Der Datentyp Error Der Datentyp Error wird zur Fehlerbehandlung benötigt (s. Datei Error.hs). data Error a = Ok a | Fail String deriving (Eq, Show, Read) Gelingt eine Aktion wird Ok <Ergebnis> zurückgegeben, ansonsten Fail <Fehlermeldung>. Will man bei zwei Ergebnissen überprüfen, ob beide Aktionen ohne Fehler ausgeführt wurden, verwendet man den Infix-Operator &. infixr 5 (&):: Error a -> Error a (&) (Fail str) _ (&) _ (Fail str) (&) _ _ -> Error () = Fail str = Fail str = Ok () Will man nun auf ein Ergebnis vom Typ Error eine weitere Funktion f anwenden, gibt man die Fehlermeldung x aus, falls das Ergebnis Fail x ist. Ansonsten, falls das weiter zu verarbeitende Ergebnis Ok a ist, führt man die Funktion f mit Eingabeparameter a aus. Hierfür gibt es den Infix-Operator >=>. 8 infixr 3 >=> (>=>):: Error a -> (a -> Error b) -> Error b (>=>) (Fail str) _ = Fail str (>=>) (Ok a ) f = f a Außerdem wird die Funktion isOk bereitgestellt, die prüft, ob die Eingabe vom Typ Error von der Form Ok a ist. isOk :: Error a -> Bool isOk (Ok x) = True isOk _ = False Wenn man schon weiß, dass kein Fehler aufgetreten ist und man möchte nur das eigentliche Ergebnis haben, ohne den Konstruktor Ok des Error-Datentyps, so verwendet man die Funktion theOk. theOk :: Error a -> a theOk (Ok x) = x 4.1.2 Parserkombinatoren Für die Umsetzung von Scanner (Lexikalische Analyse s. 4.3) und Parser (Syntaktische Analyse s. 4.4) gibt es sehr nützliche Hilfsfunktionen (s.Datei Parsecombs.hs). Die zugrundeliegende Datenstruktur Parse ist wie folgt deklariert: type Parse a b = [a] -> Error (b, [a]) Ein Parser ist also eine Funktion, die als Eingabewert eine Liste vom Typ a erwartet. Sind die am Anfang stehenden Werte in der Liste genau die Werte, die der Parser erwartet, so spaltet der Parser die Werte von der Liste ab, wandelt sie in einen anderern Typ um und gibt ein Tupel zusammen mit dem Konstruktor Ok zurück. Das Tupel enthält als erstes Argument den umgewandelten Typ und als zweites Argument die restlichen Werte der Liste. Ist der Anfang der Eingabeliste jedoch nicht das, was der Parser erwartet, so wird Fail ¨Syntax error¨ zurückgegeben. Beispiel: Ein ganz einfacher Parser ist der alphaParser, der überprüft ob das nächste Zeichen in der Liste aus dem Alphabet ist (die Hilfsfunktion spot ist in der Datei Parsecombs.hs definiert): alphaParser :: Parse Char Char alphaParser = spot isAlpha Übergibt man ihm beispielsweise den String ¨Haskell¨ erhält man: alphaParser "Haskell" ==> Ok (’H’, "askell") Enthält der String aber am Anfang eine Zahl, ist die Ausgabe: alphaParser "123 Haskell" ==> Fail "Syntax error" Beispiel: In vorherigen Beispiel war der Typ des Parsers Parse Char Char. Laut Definition müssen aber der Typ der Eingabeliste und der abgespaltenen Werte nicht identisch sein. Deshalb wird jetzt jedes erkannte Zeichen in ihren ASCII-Wert umgwandelt (die Hilfsfunktion >>> ist in der Datei Parsecombs.hs definiert). alpha2ASCIIParser :: Parse Char Int alpha2ASCIIParser = spot isAlpha >>> ord Übergibt man dem Parser jetzt den String ¨Haskell¨ erhält man: alpha2ASCIIParser "Haskell" ==> Ok (72, "askell") Einen Parser für eine komplizierte Sprache setzt man am besten nach dem ¨divide and conquer¨ Verfahren aus vielen kleinen Parsern zusammen. Damit man aber alle kleinen Parser in einer Funktion verwenden kann, werden noch Hilfsfunktionen benötigt, die diese Parser miteinander kombinieren (Parserkombinatoren), um so den gewünschten Parser zu erhalten. 9 Beispiel: Dieses Beispiel soll zeigen wie die Kombination von Parsern funktioniert. Dabei ist es unwichtig, wie die einzelnen Parser implementiert sind. Für SimpleJava sieht der while-Schleifen Parser folgendermaßen aus (für die Definition von Symbol siehe 4.3 und für Statement siehe 2.2): whileParser :: Parse Symbol Statement whileParser = token WhileSym $- conditionParser &&& statementParser >>> (\(cond, stmt) -> While cond stmt) In diesem Beispiel sieht man, dass der Parser für while-Schleifen durch die Kombination von anderen Parsern entsteht. Diese sind im einzelnen: • token WhileSym • conditionParser • statementParser Diese Parser können wiederum durch Kombination von Parsern implementiert sein. Außerdem werden in diesem Beispiel folgenden Hilfsfunktionen zur Kombination verwendet (für die Bedeutung siehe nächstes Beispiel und Beschreibung der Hilfsfunktionen der Datei Parsecombs.hs): • $• &&& • >>> An diesem Beispiel sieht man, dass die Hilfsfunktionen selbst schon Parser sind, denn nur so ist gewährleistet, dass man am Ende wieder einen Parser erhält. Beispiel: Die Arbeitsweise des while-Parsers wird nun noch anhand eines konkreten Beispiels besser verdeutlicht. Eine mögliche while-Schleife in einem SimpleJava Program ist: while (x < y) x = (x+1); Nach der lexikalischen Analyse erhält man als Eingabeliste für den while-Parser: [WhileSym,OpenPar,Ident "x",RelOp LessThan,Ident "y",ClosePar, Ident "x",AssignOp,OpenPar,Ident "x",ArithOp Plus,Num 1,ClosePar,Semicolon] Durch den Aufruf ¨whileParser <Eingabeliste>¨ erhält man: Ok (While (Condition (Variable "x") LessThan (Variable "y")) (Assignment "x" (Compound (Variable "x") Plus (Number 1))),[]) Die einzelnen Parser haben dabei folgende Ein- und Ausgaben: Parser token WhileSym $- conditionParser &&& statementParser >>> (\(co...mt) Eingabe Ausgabe Erläuterung [WhileSym,OpenPar, Ok (WhileSym,[OpenPar, erkennt Symbol Ident ¨x¨, ...,Semicolon] Ident ¨x¨, ...,Semicolon]) WhileSym Wirft das erkannte Symbol WhileSym weg und ruft conditionParser auf. Das Symbol wird verworfen, da es für die abstrakte Syntax keine Bedeutung hat, aber im SimpleJava Code die while-Schleife einleitet. [OpenPar,Ident ¨x¨, ..., Ok (Condition (Variable ¨x¨) erkennt die ClosePar,Semicolon] LessThan (Variable ¨y¨), Bedingung der [Ident ¨x¨,AssignOp, ..., while-Schleife ClosePar,Semicolon]) Merkt sich die Bedingung, ruft den statementParser auf und kombiniert am Ende die Ergebnisse von conditionParser und statementParser zu einem Tupel [Ident ¨x¨,AssignOp, ..., Ok (Assignment ¨x¨ (Compound erkennt den ClosePar,Semicolon] (Variable ¨x¨) Plus Anweisungsteil (Number 1)),[]) der while-Schl. Wandelt das Ergebnis aus allen obigen Parsern in ein Statement um. 10 In der Datei Parsecombs.hs sind folgende Hilfsfunktionen implementiert : succeed ist der Parser, der grundsätzlich ein Zeichen akzeptiert, unabhängig von der Eingabeliste. succeed :: b -> Parse a b succeed val xs = Ok (val, xs) Ähnlich verhält sich empty. Dieser Parser akzeptiert immer die leere Liste und gibt als Restliste die Eingabeliste zurück. empty :: Parse a [b] empty xs = Ok ([], xs) Mit dem Infix-Operator ||| hat man alternativ zwei Parser zur Verfügung. Akzeptiert der erste Parser kein Eingabezeichen aus der Liste, wird der zweite ausprobiert. Nur wenn keiner der beiden Parser passt, erhält man eine Fehlermeldung. infixl 0 ||| (|||) :: Parse a b -> Parse a b -> Parse a b (|||) p1 p2 xs = eval (p1 xs) p2 xs where eval :: Error (b, [a]) -> Parse a b -> Parse a b eval (Fail _) p2 xs = p2 xs eval res _ _ = res Zwei Parser lassen sich hintereinander ausführen mit dem Infix-Operator &&&. Das Ergebnis beider Parser wird als Tupel vom Typ (b,c) zurückgegeben. infixr 5 &&& (&&&) :: Parse a b -> Parse a c -> Parse a (b,c) (&&&) p1 p2 xs = p1 xs >=> (eval p2) where eval p2 (x, ys) = p2 ys >=> (combine x) combine x (y, zs) = Ok ((x, y), zs) Hat ein Parser ein Eingabezeichen (oder mehrere) akzeptiert, so kann man auf dieses Ergebnis mit dem InfixOperator >>> eine Funktion f anwenden. Die Restliste, die an den nächsten Parser weitergegeben wird, bleibt unverändert. infix 3 >>> (>>>) :: Parse a b -> (b -> c) -> Parse a c (>>>) p f xs = p xs >=> \(x, ys) -> Ok (f x, ys) Die Funktion many führt einen Parser so oft hintereinander aus, bis der Parser kein Eingabezeichen mehr erkennt. Die akzeptierten Werte werden in einer Liste zusammengefasst. many :: Parse a b -> Parse a [b] many p = (p &&& many p) >>> (uncurry (:)) ||| (succeed []) many1 hat die gleiche Funktionalität wie many, außer dass mindestens ein Zeichen der Eingabeliste akzeptiert werden muss. many1 :: Parse a b -> Parse a [b] many1 p = p &&& many p >>> (uncurry (:)) Der Parser spot akzeptiert ein Eingabezeichen genau dann, wenn es eine bestimmte Bedingung (boolesche Funktion) erfüllt. 11 spot :: (a -> Bool) -> Parse a a spot f (x:xs) | f x = Ok (x, xs) | otherwise = Fail "Syntax error" spot f [] = Fail "Syntax error" Ein Sonderfall von spot ist die Funktion token. Dieser Parser erwartet ein ganz bestimmtes Eingabezeichen. token :: Eq a => a -> Parse a a token t = spot (==t) Will man nicht nur ein einzelnes Zeichen vorgeben, sondern eine Liste von Zeichen, so gibt es dafür die Funktion tokenList. tokenList :: Eq a => [a] -> Parse a [a] tokenList [] = empty tokenList (x:xs) = token x &&& tokenList xs >>> (uncurry (:)) Der Infix-Operator $- bewirkt, dass zwei Parser hintereinander angewandt werden. Allerdings wird das akzeptierte Ergebnis des ersten Parsers weggeworfen und nur das des zweiten ausgegeben. infix 6 $($-) :: Parse a b -> Parse a c -> Parse a c ($-) p1 p2 = p1 &&& p2 >>> snd Ebenso verhält sich -$, nur dass das Ergebnis des zweiten Parsers verworfen und das des ersten zurückgegeben wird. infix 6 -$ (-$) :: Parse a b -> Parse a c -> Parse a b (-$) p1 p2 = p1 &&& p2 >>> fst Schließlich gibt es noch die Funktion optional, die zunächst versucht einen Parser auf die Eingabeliste anzuwenden. Gelingt dies nicht, wird einfach ein vorgegebenes Ergebnis akzeptiert. optional :: Parse a b -> b -> Parse a b optional p def = p ||| (succeed def) 4.2 Beispielprogramm Dieses SimpleJava Programm berechnet die Fakultätsfunktion. x ist der Eingabewert, das Ergebnis wird in der Variablen erg gespeichert. int x, erg; x = 5; if (x < erg = else { erg = while { erg x = } } 0) (-1); 1; (x > 1) = (erg * x); (x - 1); 12 Dieses Beispiel wird im folgenden bei der Erklärung der einzelnen Teile des Compilers verwendet. 4.3 Lexikalische Analyse (Scanner) Der Scanner liest das Quellprogramm in Form einer Zeichenfolge und zerlegt diese Zeichenfolge in eine Folge von lexikalischen Einheiten der Programmiersprache, Symbole genannt. Leerzeichen und Sonderzeichen, wie Zeilenschaltung und Tabulatoren werden ausgesiebt. Für die Symbole ist der Datentyp Symbol folgendermaßen definiert (siehe auch Datei SJSymbol): data Symbol = ArithOp ArithOp | RelOp RelOp | | | | AssignOp IfSym ElseSym WhileSym | IntType | Ident Identifier | Num Integer -- + - * / -- == != < <= > >= ----- = if else while -- int -- Variablenname -- Zahl | OpenPar -| ClosePar -| OpenBrace -| CloseBrace -| Colon -| Semicolon -deriving (Eq, Show, Read) ( ) { } , ; Der Scanner wird hier durch die Funktion scan realisiert. Falls ein Eingabezeichen nicht erkannt wird, gibt die Funktion einen Fehler mit Hilfe des Error Datentyps zurück. scan :: [Char] -> Error [Symbol] Beispiel: Bei Eingabe des Beispielprogramms (s. 4.2) erhält man folgende Ausgabe: Ok [IntType,Ident "x",Colon,Ident "erg",Semicolon, Ident "x",AssignOp,Num 5,Semicolon, IfSym,OpenPar,Ident "x",RelOp LessThan,Num 0,ClosePar, Ident "erg",AssignOp,Num (-1),Semicolon, ElseSym, OpenBrace, Ident "erg",AssignOp,Num 1,Semicolon, WhileSym,OpenPar,Ident "x",RelOp GreaterThan,Num 1,ClosePar, OpenBrace, Ident "erg",AssignOp,OpenPar,Ident "erg",ArithOp Times,Ident "x",ClosePar,Semicolon, Ident "x",AssignOp,OpenPar,Ident "x",ArithOp Minus,Num 1,ClosePar,Semicolon, CloseBrace, CloseBrace] 4.4 Syntaktische Analyse (Parser) Die syntaktische Analyse soll die über die lexikalische hinausgehende Struktur des Programms herausfinden. Sie kennt den Aufbau von Ausdrücken, Anweisungen, Deklarationen und von Auflistungen von solchen Konstrukten, und sie versucht, in der gegebenen Symbolfolge die Struktur eines Programms zu erkennen. Unser Parser erzeugt aus einer Symbolliste, die der Scanner liefert, einen abstrakten Syntaxbaum. Wie dieser Baum aufgebaut wird, ist im Modul SJAbstractSyntax definiert. Im Fehlerfall wird wieder eine Fehlermeldung zurückgegeben. 13 parser :: [Symbol] -> Error Program Beispiel: Die Ausgabe des Scanners wird im Parser weiterverarbeitet. Das Ergebnis bei unserem Beispielprogramm sieht dann so aus: Ok (Program ["x","erg"] [Assignment "x" (Number 5), If (Condition (Variable "x") LessThan (Number 0)) (Assignment "erg" (Number (-1))) (Sequence [Assignment "erg" (Number 1), While (Condition (Variable "x") GreaterThan (Number 1)) (Sequence [Assignment "erg" (Compound (Variable "erg") Times (Variable "x")), Assignment "x" (Compound (Variable "x") Minus (Number 1))] )] ) ] ) 4.5 Semantische Analyse Bei der semantischen Analyse wird überprüft, ob • alle im Programm verwendeten Variablen deklariert wurden; • keine Variablen mehrfach deklariert wurden; • die Variablen initialisiert wurden bevor sie im Programm verwendet werden; • alle Zuweisungen typkonform sind und ob alle Funktionsparameter den erwarteten Typ haben. Daher nennt man den Analyser auch Typechecker. SimpleJava enthält jedoch nur den Datentyp int. Deshalb ist eine Prüfung der Typen in diesem Fall nicht nötig. Der Analyser für SimpleJava überprüft einen abstrakten Syntaxbaum auf die drei ersten Punkte hin und liefert eine Fehlermeldung, wenn einer davon nicht erfüllt ist. Ansonsten ist die Ausgabe einfach Ok (). analyse :: Program -> Error () Beispiel: In unserem Beispiel liefert der Analyser das Ergebnis Ok () Dies war zu erwarten, da das Eingabeprogramm korrekt ist. 4.6 Codegenerator Der Codegenerator übersetzt nach der semantischen Analyse den Syntaxbaum, den der Parser geliefert hat, in ein Maschinenprogramm, das von unserer virtuellen PMI Maschine ausgeführt werden kann. generateCode :: Program -> MachineProgram Variablen werden als ¨lokale Variablen¨ übersetzt, sodass man später vielleicht noch Prozeduren in SimpleJava einbauen kann. Jedes SimpleJava Programm wird also als Prozedur betrachtet. Für jeden Prozeduraufruf wird zur Laufzeit ein Bereich auf dem Stack angelegt, in dem die Parameter der Prozedur (in diesem Fall nicht vorhanden), die lokalen Variablen und der Operandenstack liegen. Dieser Bereich wird Stackframe genannt. Lokale Variablen liegen am Anfang des Stackframes. Um die Variablen später zu adressieren, braucht man immer den 14 Anfang des Stackframes. Die PMI hat aber kein Register für diese Adresse, sondern nur ein Register, das auf das oberste Element des Stacks zeigt. Man kann aber zur Übersetzungszeit den Anfang des Stackframes berechnen. Dazu muss zunächst eine Symboltabelle aufgebaut werden, die jeder deklarierten Variablen einen Index zuweist. Die Variablen werden dann in der Reihenfolge aufsteigender Indizes auf dem Stack abgelegt. Außerdem muss bei der Übersetzung immer ein Offset mitgeführt werden, der angibt, wieviele Elemente (einschließlich der lokalen Variablen) gerade auf dem Stack liegen. Mit Hilfe des Offsets und der Indexnummer der Variablen in der Symboltabelle kann zu jeder Zeit der Generierung einer neuer Offset berechnet werden, der den Abstand der Variablen zum Stack Pointer angibt. Beispiel: Nachdem der Syntaxbaum aus 4.4 semantisch analysiert und als korrekt bewertet wurde, kann nun der Maschinencode generiert werden. Das Ergebnis sieht wie folgt aus: [Push (Immediate (Absolute 0)), Push (Immediate (Absolute 0)), Push (Immediate (Absolute 5)), Pop (Immediate (SP 2)), Push (Direct (SP 1)), Push (Immediate (Absolute 0)), Sub, Comp, Del, JmpN (Immediate (PC 3)), Push (Immediate (Absolute 0)), Jump (Immediate (PC 2)), Push (Immediate (Absolute 1)), Comp, Del, JmpZ (Immediate (PC 4)), Push (Immediate (Absolute (-1))), Pop (Immediate (SP 1)), Jump (Immediate (PC 26)), Push (Immediate (Absolute 1)), Pop (Immediate (SP 1)), Jump (Immediate (PC 9)), Push (Direct (SP 0)), Push (Direct (SP 2)), Mult, Pop (Immediate (SP 1)), Push (Direct (SP 1)), Push (Immediate (Absolute 1)), Sub, Pop (Immediate (SP 2)), Push (Direct (SP 1)), Push (Immediate (Absolute 1)), Sub, Comp, Del, JmpN (Immediate (PC 4)), JmpZ (Immediate (PC 3)), Push (Immediate (Absolute 1)), Jump (Immediate (PC 2)), Push (Immediate (Absolute 0)), Comp, Del, JmpZ (Immediate (PC 2)), Jump (Immediate (PC (-21))), Halt] 15 4.7 Test mit der PMI Das Maschinenprogramm, das mit dem Codegenerator erzeugt wird, kann nun mit Hilfe der PMI getestet werden. Bei Eingabe des Beispielprogramms, stehen im Speicher am Ende folgende Werte: Stack nach Ablauf des Programms: (15999,Value 1) -- x = 1 (15998,Value 120) -- erg = 120 16