Java für Fortgeschrittene Proseminar im Sommersemester 2009 Compilertechnik - Parser, Scanner & Co. Andrea Cuno Technische Universität München 22.06.2009 Zusammenfassung Reguläre Ausdrücke in Java bieten die Möglichkeit, Strings unter bestimmten Aspekten zu betrachten. Je nach Absicht können Zeichen eines Strings erlaubt, erzwungen, eine bestimmte Reihenfolge oder Anzahl festgelegt werden. Für komplexere Ausdrücke reichen die von Java angebotenen Mittel und Möglichkeiten mitunter nicht. Stattdessen kann es für spezifische Problemstellungen sinnvoll sein, mit Hilfsprogrammen passende Scanner und Parser zu erstellen. Die beiden Programme CUP und JFlex stellen solche Hilfsprogramme dar, die auch im Compilerbau eingesetzt werden. Die generierten Parser und Scanner bestehen aus Java-Code und dienen der Bearbeitung und Auswertung von regulären Ausdrücken. 1 Einleitung Das Ziel dieser Arbeit ist es, dem Leser einen Überblick über die wichtigsten Mittel und Möglichkeiten im Umgang mit regulären Ausdrücken, Parsern und Scannern zu geben. In den folgenden Kapiteln werden zunächst reguläre Ausdrücke in Java behandelt, gefolgt von einem Überblick über die Funktionsweise eines Compilers, sowie weitergehenden Erläuterungen zu den Themen Scanner und Parser. Reguläre Ausdrücke benötigt man in der Informatik häufig, um Eingaben differenziert auszuwerten und zu bearbeiten. Allerdings werden Java-Programme zur Auswertung von komplexen regulären Ausdrücken schnell unübersichtlich und somit fehleranfällig. An dieser Stelle können Scanner generiert werden, die dann die Auswertung beziehungsweise Bearbeitung der regulären Ausdrücke übernehmen. In einem Kapitel zur lexikalischen Analyse wird die Aufgabe eines Scanners näher erläutert. In diesem Zusammenhang wird anschließend der Umgang mit JFlex (Fast Scanner Generator for Java) [4] kurz vorgestellt. Scanner und Parser arbeiten in der Form zusammen, dass der Scanner die erfassten Zeichenfolgen, sogenannte Tokens, an den Parser übergibt, die dieser dann weitergehend behandelt. Parser sind generell eine 1 Stufe mächtiger als Scanner, denn sie erlauben die Erkennung mächtigerer Sprachmittel. Das heißt, beispielsweise bei geklammerten Ausdrücken, dass der Parser die offenen und geschlossenen Klammern zählt und so die zusammengehörenden Klammern entsprechend erfasst werden. Nach einer Einführung in die semantische Analyse, die Aufgabe des Parsers, folgt ein Kapitel zu dem Programm CUP (Constructor of Useful Parsers) [3], das der Erstellung von Parsern dient. 2 Reguläre Ausdrücke Reguläre Ausdrücke stellen eine Möglichkeit dar, um Muster von Zeichenketten zu beschreiben. Sie werden unter anderem zum Suchen von und in Strings und zum Definieren von formalen Sprachen verwendet. Die nachfolgende Tabelle enthält einige grundlegende Regeln für reguläre Ausdrücke. Eine ausführlichere Liste ist unter [6] zu finden. Regulärer Ausdruck [abc] [ˆabc] [a-d[m-p]] [a-z&&[def]] [a-z&&[ˆbc]] [a-z&&[ˆm-p]] . a* a? a+ a{n,m} a{n} a{n,} (hallo) ˆ $ Bedeutung a, b, oder c jedes Zeichen außer a, b, oder c (= ˆ Negation) a bis d oder m bis p: [a-dm-p] (= ˆ Vereinigung) d, e, oder f (= ˆ Schnitt) a bis z außer b und c: [ad-z] (= ˆ Subtraktion) a bis z, aber nicht m bis p; entspricht [a-lq-z] ein beliebiges Zeichen kein oder beliebig viele a kein oder ein a ein oder beliebig viele a a mindestens n und höchstens m-mal a genau n-mal a mindestens n-mal fasst die Buchstaben h,a,l,l,o zu einer Einheit zusammen (Capturing-Group) Anfang einer Zeile Ende einer Zeile Tabelle 1: Reguläre Ausdrücke Im Folgenden werden einige Regeln zu regulären Ausdrücken ausführlicher beschrieben. Innerhalb der eckigen Klammern können verschiedene Mengen von Zeichen zusammengefasst, oder Teilmengen ausgeschlossen werden. Demzufolge bedeutet [a-dm-p] (siehe Tabelle 1), dass Buchstaben von a bis d und von m bis p erlaubt sind. Bei Zahlenangaben ist bei dieser Schreibweise darauf zu achten, dass der Ausdruck [1-12] nicht etwa Zahlen von eins bis zwölf erlaubt. 2 Der Ausdruck muss stattdessen so interpretiert werden, dass Zahlen von eins bis eins und die Ziffer zwei zu der Menge der zugelassenen Zahlen gehören. Die Zahlen von eins bis zwölf könnte man so darstellen: ([1 − 9]|10|11|12)$. Eine andere Variante wäre ([1 − 9]|(1[0 − 2]))$. Die Schnittmenge zweier Mengen lässt sich ebenfalls darstellen. Ein Beispiel hierfür ist [a − z&&[d − f ]]. Der Ausdruck entspricht einer Menge mit den Buchstaben d, e und f. Mit ˆ kann ein Zeichen oder eine Zeichengruppe negiert werden. [a − z&&[ˆd − f ]] beschreibt dementsprechend eine Menge, in der die Buchstaben a bis z außer d, e und f, erlaubt sind. Das Sonderzeichen ˆ hat außerdem eine weitere Funktion, die im Folgenden deutlich wird: Der Ausdruck [ˆabc] bedeutet, dass a, b und c nicht auftreten dürfen. Schreibt man dagegen ˆ[abc], heißt das, dass a, b oder c am Anfang einer Zeile stehen muss. $ ist das entsprechende Zeichen für das Ende einer Zeile. ˆa$ würde folglich bedeuten, dass eine Zeile nur aus einem einzelnen a bestehen darf. Alternative reguläre Ausdrücke werden durch das Sonderzeichen | getrennt. Die Funktion von Sonderzeichen, wie ˆ und |, wird aufgehoben, sobald der escape-character für reguläre Ausdrücke, ein Backslash, davor steht. Um festzulegen, wie oft ein Zeichen oder eine Zeichenfolge hintereinander auftreten darf, gibt es die sogenannten Quantifiers +, * und ?. Das PlusZeichen bedeutet, dass der davor stehende Ausdruck beliebig oft auftreten darf, jedoch mindestens einmal. Im Gegensatz dazu, erlaubt * zusätzlich, dass der Ausdruck nicht erscheint. Wenn gewünscht ist, dass ein Ausdruck nicht oder höchstens einmal auftritt, ist ein anschließendes Fragezeichen zweckgemäß. Um speziellere Mengenangaben festzulegen, können dem Ausdruck geschweifte Klammern mit der zulässigen Mindest- und Höchstanzahl folgen. X{3,4} verlangt, dass das Zeichen X genau drei- oder viermal auftritt. Bei regulären Ausdrücken lässt sich das gleiche Muster oft durch verschiedene Varianten ausdrücken. Beispielsweise ist der Ausdruck “xxx?x?” nur eine andere Schreibweise für x{2,4}, da die ersten beiden x-Zeichen, denen kein Fragezeichen folgt, in jedem Fall auftreten müssen, die beiden anderen jedoch jeweils einmal oder gar nicht. In Java werden für die Anwendung von regulären Ausdrücken Patternund Matcher-Objekte zur Verfügung gestellt (siehe Tabellen 2, 3). 3 Rückgabewert Pattern Matcher boolean String Pattern Methodenname Beschreibung compile(String regex) Compiles the given regular expression into a pattern. matcher(CharSequence Creates a matcher that will input) match the given input against this pattern. matches(String regex, Compiles the given regular exCharSequence input) pression and attempts to match the given input against it. pattern() Returns the regular expression from which this pattern was compiled. Tabelle 2: Pattern Matcher Rückgabewert boolean Methodenname matches() Pattern pattern() String replaceAll(String replacement) Matcher int reset() start() String group(int group) Beschreibung Attempts to match the entire region against the pattern. Returns the pattern that is interpreted by this matcher. Replaces every subsequence of the input sequence that matches the pattern with the given replacement string. Resets this matcher. Returns the start index of the previous match. Returns the input subsequence captured by the given group during the previous match operation. Tabelle 3: Matcher 4 Mit Hilfe des Pattern-Objekts wird der reguläre Ausdruck definiert, wie das folgende Beispiel zeigt. final Pattern carPattern = Pattern.compile("[A-Z]+[ ][A-Z]+[ ][0-9]+"); Die Methode compile() übersetzt den String in ein entsprechendes PatternObjekt. In diesem Fall würde jedes (Euro-) Auto-Kennzeichen dem dargestellten regulären Ausdruck entsprechen. Allerdings genügen diesem Ausdruck auch andere Zeichenketten, die keine korrekten Auto-Kennzeichen sind, da beliebig viele Großbuchstaben, ein Leerzeichen, erneut Großbuchstaben und ein Leerzeichen, gefolgt von beliebig vielen Zahlen in dem Ausdruck vorkommen dürfen. Um den regulären Ausdruck weiter zu präzisieren, könnte man beispielsweise die Anzahl der Ziffern festlegen. Es muss mindestens eine Ziffer enthalten sein, jedoch sind höchstens vier Ziffern zugelassen. Folgender Ausdruck ist also wesentlich präziser: "[A-Z]{1,3}[ ][A-Z]{1,2}[ ][0-9]{1,4}" Wenn ein Pattern-Objekt erstellt wurde, kann darauf aufbauend mit MatcherObjekten weitergearbeitet werden. Ein Matcher bezieht sich auf einen konkreten Textstring und bietet unter anderem eine Methode an, um den Eingabe-String mit dem regulären Ausdruck abzugleichen. Matcher m=carPattern.matcher("M XY 123"); boolean b = m.matches(); Runde Klammern fassen Zeichengruppen zu einer Einheit zusammen, sogenannte Capturing-Groups. Auf den Teil des Eingabestrings, der mit der CapturingGroup übereinstimmt, kann mithilfe des Befehls group(int i) zugegriffen werden. Pattern p = Pattern.compile("([A-Z]{1,3})[ ]([A-Z]{1,2})[ ]([0-9]{1,4})"); Matcher m; m = p.matcher("M XY 123"); System.out.println(m.matches()); //true System.out.println(m.group(1)); // M System.out.println(m.group(2)); // XY System.out.println(m.group(3)); // 123 3 Compiler Ein Compiler, (dt. Übersetzer), ist ein Programm, das Programme aus einer Sprache in eine andere Sprache übersetzt. Im sogenannten Frontend des Compilers erfolgen die analytischen Aufgaben des Compilers. Dazu zählen die lexikalische, syntaktische und semantische Analyse. In diesem Bereich sind Scanner und Parser angesiedelt. Der Scanner ist für die 5 lexikalische Analyse zuständig, der Parser übernimmt die syntaktische Analyse. Im Backend wird aus den Ergebnissen des Frontends in der Regel zuerst ein sogenannter Zwischencode erzeugt. Daraufhin erfolgt eine Optimierung des Codes und schließlich wird der Zielcode erzeugt. Abbildung 1: Aufbau eines Compilers Für die Generierung von Parsern und Scannern, können die Programme JFlex und CUP verwendet werden. Sie sind aufeinander abgestimmt und erstellen Java-Dateien. Beim Zusammenwirken der beiden Programme entstehen drei Java-Klassen, ein Parser, ein Scanner und eine weitere Klasse für die Tokens beziehungsweise Terminale oder Symbole. Mit Hilfe dieser Tokenfolgen überliefert der Scanner die Eingabe auf strukturierte Weise an den Parser, der diese weiterverarbeitet. 6 Abbildung 2: Zusammenhang: Java, JFlex, CUP 3.1 Lexikalische Analyse - Scanner Die grundlegende Aufgabe der lexikalischen Analyse ist die Zerlegung einer Eingabe in Zeichen oder Zeichenketten, sogenannte Tokens. Die Regeln für die Zerlegung werden als Liste von regulären Ausdrücken festgelegt. Ein lexikalischer Scanner, auch Lexer genannt, ist ein Computerprogramm, das für die lexikalische Analyse im Compiliervorgang zuständig ist. Typische Tokens sind Operatoren, Bezeichner, Konstanten und bestimmte Schlüsselwörter, die mit ihrem jeweiligen Typ an den Parser weitergeleitet werden. Zu den Aufgaben eines Scanners gehört auch die Erkennung unzulässiger Zeichen oder Zeichenketten. Im Scanner erfolgt also zum Beispiel die Erkennung von Ziffernfolgen oder Buchstabenfolgen als Zahlen beziehungsweise Wörter. 7 Abbildung 3: Zerlegung einer Schleife durch einen Scanner in Token 3.1.1 JFlex - The Fast Scanner Generator for Java JFlex [4] ist ein Programm, um lexikalische Scanner zu erstellen. JFlex wurde für die Programmiersprache Java geschrieben und wird häufig zusammen mit CUP verwendet (siehe Codebeispiel 1, Zeile 4). JFlex benötigt eine Input-Datei mit der Endung .jflex, um daraus einen Scanner in Form einer Java-Klasse zu erstellen. Die Syntax von JFlex weist Ähnlichkeiten mit der Java-Syntax auf, unterscheidet sich jedoch in einigen grundlegenden Merkmalen. Zunächst wird ein kurzer Überblick über die Syntax der JFlex-Datei gegeben, anschließend folgt ein Code-Beispiel mit weiteren Erläuterungen. Eine JFlex-Datei besteht aus drei Teilbereichen, die jeweils durch eine Zeile getrennt werden, die die Zeichen %% enthält. Der erste Bereich wird UserCode genannt. Hier finden die package-Deklaration und Importe statt. Darunter folgt der Abschnitt für Optionen und Deklarationen, der zum Beispiel die Zeile %cup enthält, wenn JFlex mit CUP zusammen genutzt werden soll. Des weiteren ist hier Platz für (Variablen-)Deklarationen, sowie den Konstruktor der Scanner-Klasse (siehe Codebeispiel 1, Zeilen 7-10). Der dritte und letzte Abschnitt enthält die lexikalischen Regeln, also eine Auflistung von regulären Ausdrücken mit Aktionen. Alle Zeichenmuster, die in den auszuwertenden Ausdrücken enthalten sein dürfen, müssen hier aufgeführt werden. Das ist mit Hilfe von regulären Ausdrücken möglich (siehe Codebeispiel 1, Zeile 14-20). Die lexikalischen Regeln sind nach dem Muster "Zeichen oder Zeichenfolge" { Aktionen für Zeichen/Zeichenfolge } oder regulärer Ausdruck { Aktionen für regulären Ausdruck } aufgebaut. Für jedes zugelassene Token werden in den nachfolgenden geschweif- 8 ten Klammern die Aktionen festgelegt. Wenn keine Aktionen definiert sind, wird das Zeichen beim Scannen ignoriert. Es sind in diesem Bereich Anweisungen in Form von Java-Code, wie beispielsweise System.out.println(yytext()); oder System.err.println("Illegal character": + yytext()); (siehe Codebeispiel 1, Zeile 20) möglich. Mit yytext() kann auf das aktuelle Token zugegriffen werden. In der Regel wird jedoch in den geschweiften Klammern als Aktion ein Token erzeugt und per return-Anweisung an den Parser weitergegeben. Dabei wird dem Token sein Typ zugewiesen, zum Beispiel SEMI (siehe Zeile 14). Bei der Auflistung der Zeichen und Ausdrücke mit ihren Aktionen gibt es einen Unterschied zwischen statischen Zeichen oder Zeichenketten (wie “;” ) und variablen regulären Ausdrücken, die in jeder Eingabe unterschiedlich ausfallen können (wie [0-9]+). Bei letzterem wird der genaue Inhalt, also zum Beispiel die Eingabe 99, gespeichert, so dass man im Parser über Labels auf diesen Inhalt zugreifen kann (siehe Kapitel 3.2.1). Bei der Reihenfolge in der Auflistung von regulären Ausdrücke mit ihren Aktionen sollte man auf folgende Prinzipien von JFlex achten. Beim Scannen wird die Eingabe mit der Liste der regulären Ausdrücke von oben nach unten abgeglichen. Das bedeutet, wenn beispielsweise in der ersten Zeile das Token [a-z] eingeführt wird und in der zweiten das Token a, dann wird bei der Eingabe a die erste Zeile als passender regulärer Ausdruck erkannt und die zweite Zeile wird nie zu einer Aktion führen. Wenn dem Buchstaben a in diesem Beispiel also eine besondere Funktion zukommen soll, muss das Zeichen a mit dem entsprechenden Aktionsbereich, vor [a-z] eingeführt werden. Das zweite Prinzip ist das Prinzip des longest match. In einem Beispiel wird sowohl das Zeichen + (in der ersten Zeile) als auch das Zeichen ++ (in der zweiten Zeile) als Token spezifiziert. Bei einer Eingabe ++ erkennt der Scanner, dass die Eingabe zu dem in der ersten Zeile spezifizierten Plus passt, schaut jedoch weiter, ob ein längerer Treffer existiert. Da das Token ++ einen längeren Treffer ermöglicht, wird in diesem Fallbeispiel nicht zweimal das Token + zurückgegeben, sondern einmal das Token ++, da der Scanner nach dem längsten Treffer sucht. 1 2 3 4 5 6 7 8 9 10 11 12 package packagename; import java_cup.runtime.SymbolFactory; %% %cup %class Scanner %{ public Scanner(java.io.InputStream r, SymbolFactory sf){ this(r); this.sf=sf; } private SymbolFactory sf; %} 9 13 %% 14 ";" { return sf.newSymbol("Semicolon",sym.SEMI); } 15 "+" { return sf.newSymbol("Plus",sym.PLUS); } 16 "-" { return sf.newSymbol("Minus",sym.MINUS); } 17 [0-9]+ { return sf.newSymbol("Integral Number",sym.NUMBER, 18 new Integer(yytext())); } 19 [ \t\r\n\f] { /* ignore white space. */ } 20 . { System.err.println("Illegal character: " + yytext()); } Codebeispiel 1: JFlex In diesem Code-Beispiel ist eine vollständige JFlex-Datei zu sehen. Es werden neben Zahlen nur Semikola, Plus- und Minuszeichen als gültige Eingabe vom Scanner erlaubt. Eine solche Eingabe wäre beispielsweise “8+33;” oder “11 ;”. Allerdings wären auch “11+” oder “;8” lexikalisch korrekt. Die syntaktische Richtigkeit zu prüfen, ist Aufgabe des Parsers (siehe Kapitel 3.2.1). In den geschweiften Klammern wird in der return-Anweisung das neue Symbol benannt und gegebenfalls ein Typ für das Symbol festgelegt. Mit yytext() kann auf das aktuelle Token zugegriffen werden (siehe Zeile 20), beispielsweise um ein neues Objekt vom Typ Integer zu erstellen (siehe Zeile 18). Um weitere Informationen einzufordern, gibt es neben yytext() noch yylength() und yyline() für die Länge, beziehungsweise die Zeile des aktuellen Tokens . Mit return wird das Token weitergereicht. Gegebenfalls kann vor der returnAnweisung weiterer (Java-)Code stehen, wie System.out.print(yytext()). In Zeile 19 wird ersichtlich, dass alle Leerzeichen, sowie Tabstopps und Leerzeilen in diesem Beispiel ignoriert werden sollen, das heißt, sie lösen keine Fehlermeldung aus, werden aber vom Scanner auch nicht weitergegeben. Die anderen spezifizierten Symbole werden hingegen vom Scanner als Tokens an den Parser übergeben, wo sie weiter behandelt werden können. In der letzten Zeile wird festgelegt, dass bei der Eingabe von allen Zeichen, außer der vorher behandelten, die Fehlermeldung “Illegal character:” ausgegeben wird. JFlex bietet auch die Möglichkeit, verschiedene Zustände, sogenannte States, zu verwalten. Standardmäßig wird der YYINITIAL-Zustand verwendet, der auch immer den anfänglichen Zustand darstellt. Allerdings gibt es Situationen, in denen es sinnvoll ist, nicht nur den Standardzustand zu verwenden. Nachfolgendes Beispiel soll veranschaulichen, wann und warum Zustände nötig sein können. Einem Bezeichner soll ein String zugewiesen werden und diese Anweisung soll außerdem mit einem Semikolon abgeschlossen werden. Allerdings soll der String neben verschiedenen Zeichen auch Semikola enthalten dürfen. Eine mögliche Eingabe ist also zum Beispiel x=";"; An dieser Stelle ist der Einsatz von unterschiedlichen Zuständen sinnvoll, da das Semikolon manchmal nur Bestandteil eines Strings ist und manchmal eine andere, besondere Funktion erfüllen soll. Mit unterschiedlichen Zuständen können Semikola in diesen beiden Kontexten unterschiedlich behandelt werden. 10 Neben dem standardmäßigen Zustand YYINITIAL können weitere Zustände definiert werden, wie zum Beispiel der state STRING. Von einem Zustand in einen anderen zu wechseln, erfolgt mit der Methode yybegin(). Mit yybegin(STRING) wechselt man zum Beispiel in den String-Zustand. Als sinnvolle Lösung für die oben beschriebene Problemstellung könnte man folgenden Code verwenden: 1 StringBuffer text = new StringBuffer(); 2 ... 3 <YYINITIAL> { 4 "\"" { text.setLength(0); yybegin(STRING); } 5 ";" { return sf.newSymbol("Semikolon",sym.SEMI); } 6 ... 7 } 8 9 <STRING> { 10 "\"" { yybegin(YYINITIAL); return symbol(sym.STRINGLITERAL, 11 text.toString()); } 12 [^\n\r\"\]+ { text.append(yytext()); } 13 } 14 . { System.err.println("Illegal character: "+yytext());} Codebeispiel 2: States in JFlex Im Beispiel bewirken die Anführungsstriche einen Statewechsel (siehe Codebeispiel 2, Zeilen 4, 10). Wenn sich der Scanner im YYINITIAL-State befindet, leiten Anführungsstriche einen String ein und somit wird im auszuführenden Code der State zu STRING gewechselt (siehe Zeile 4). Wenn sich im Eingabetext keine Anführungszeichen befinden, bleibt der Scanner im YYINITIAL-Zustand und die dafür erlaubten Zeichen werden wie gewohnt behandelt (siehe Zeile 5). Befindet sich der Scanner im STRING-Zustand, werden alle Zeichen außer Zeilenumbrüche und Anführungsstrichen dem String text angehängt (siehe Zeile 12). Tritt ein Anführungszeichen auf, wird der Zustand gewechselt und der String text, der beliebig viele Zeichen enthalten kann, wird zurückgegeben (siehe Zeilen 10,11). 3.2 Syntaktische Analyse - Parser Die Hauptaufgabe des Parsers ist die Überführung der Tokens in eine strukturierte Darstellung, zum Beispiel einen Syntaxbaum. Des weiteren sorgt der Parser für die Erkennung von syntaktischen Fehlern und ihres Entstehungsorts. 11 3.2.1 CUP - Constructor of Useful Parsers Das Programm CUP ist in Java geschrieben und der von CUP erstellte Code ist ebenfalls Java-Code. Ähnlich wie JFlex benötigt CUP eine Datei mit der Endung .cup, um eine entsprechende Java-Klasse zu erstellen - den Parser. Im Folgenden sind einige wesentliche Bestandteile einer CUP-Datei beschrieben. 1 package packagename; 2 import java_cup.runtime.*; ... 3 terminal SEMI, PLUS, MINUS; 4 terminal Integer NUMBER; 5 non terminal Integer expr; ... 6 expr::= NUMBER:n {: RESULT=n; :} 7 | expr:l PLUS expr:r {: RESULT=(l+r); :} 9 | expr:l MINUS expr:r {: RESULT=(l-r); :} 10 ; Codebeispiel 3: CUP Die Zuordnung zum zugehörigen Paket, sowie die Importe von Klassen und Paketen sind genauso gehalten wie in Java. Im Programm sind vor allem die Deklaration der Terminale und Nonterminale, sowie die Definition der Produktionen typisch. Bei der Deklaration der (Non-)Terminale können JavaTypenbezeichnungen vor dem Namen des (Non-)Terminals stehen (siehe Zeilen 4, 5). Das Nonterminal expr ist beispielsweise vom Wert Integer (siehe Zeile 5). Typenbezeichnungen für Terminale und Nonterminale sind aber nicht zwingend notwendig (siehe Zeile 3). In der Grammatik ist die Definition für jedes Nonterminal nach dem Muster beispiel::= BSP1:i1 BSP2:i2 ... {: //Action :} | ... ; aufgebaut. Im Folgenden wird die linke und rechte Seite, wie sie im Muster zu sehen ist, als LeftHandSide(LHS), beziehungsweise RightHandSide(RHS) bezeichnet. Die LeftHandSide (LHS) enthält das zu spezifizierende Nonterminal. Die RightHandSide(RHS) ist wie folgt aufgebaut: Eine RHS kann durch mehrere mögliche Varianten repräsentiert werden (siehe Zeilen 6-10). Diese Varianten werden durch | getrennt. Für jedes Terminal oder Nonterminal, das in der RHS auftritt, können Labels vergeben werden, also zum Beispiel n bei NUMBER. Mithilfe des Labels kann in den semantischen Aktionen beim Abarbeiten einer Regel auf den Inhalt des Terminals oder Nonterminals zugegriffen werden. Die semantischen Aktionen entsprechen Java-Code und sind von Doppelpunkten und geschweiften Klammern umgeben. Über die Variable RESULT wird das Ergebnis, auf das eine Regel reduziert wird, zurückgeliefert. RESULT ist immer vom selben Typ wie 12 das entsprechende Nonterminal auf der LeftHandSide. In dem behandelten Beispiel ist RESULT (siehe Zeilen 6-9) daher vom Typ Integer, da das nonterminal expr auf der LHS ebenfalls den Typ Integer hat. Das Nonterminal expr repräsentiert mehrere Ausdrücke. Eine einzelne Zahl (NUMBER:n) ist zum Beispiel ein gültiger Ausdruck. Ebenso möglich sind Additionen (expr:l PLUS expr:r), Subtraktionen (expr:l MINUS expr:r) oder eine Schachtelung dieser Ausdrücke. Semantische Aktionen sind optional, das heißt, die folgende Grammatik ohne semantische Aktionen für das Beispiel wäre wie folgt: expr ::= NUMBER | expr PLUS expr | expr MINUS expr ; Das Nonterminal auf der LeftHandSide kann auch in der RightHandSide rekursiv als Bestandteil einer oder mehrerer Varianten auftreten. Bei der Eingabe 1-5 greift zum Beispiel zunächst die Regel expr:l MINUS expr:r. Für expr:l und expr:r muss jeweils erneut eine passende Variante von expr ausgewählt werden, was in diesem Fall zweimal NUMBER:n ist. Abbildung 4: Beispielbaum(1) 13 Abbildung 5: Beispielbaum(2) Wenn Regeln für den Parser nicht völlig eindeutig definiert sind, kann es zu sogenannten Shift/Reduce- beziehungsweise Reduce/Reduce-Konflikten kommen. Häufig tritt der Shift/Reduce-Konflikt zum Beispiel im Zusammenhang mit geschachtelten if-then-else-Anweisungen auf, wenn unklar ist, welches else zu welchem if gehört. Auch wenn man das expr-Beispiel um die Funktion Multiplikation erweitern würde, tritt ein solcher Konflikt auf. Bei der Eingabe 4*2+3 kann der Parser dann nicht erkennen, welche Regel zuerst greifen soll (expr PLUS expr oder expr TIMES expr). Es entsteht ein Shift/Reduce-Conflict, der mit Hilfe von sogenannten precedences gelöst werden kann. Um beispielsweise der Multiplikation eine höhere Priorität als der Addition und Subtraktion zu geben, schreibt man: precedence left PLUS; precedence left MINUS; precedence left TIMES; Hier ist die Reihenfolge der Auflistung von Bedeutung. Der untersten Zeile wird hierbei die höchste Prioriät zugewiesen. Die Festlegung der precedences erfolgt in der CUP-Datei nach der Deklaration der Terminals und Nonterminals. Neben dem Shift/Reduce-Conflict, gibt es außerdem den Reduce/ReduceConflict. Dieser Konflikt entsteht, wenn für ein und dasselbe Token zwei Regeln angewendet werden können. Der Scanner weiß in einem solchen Fall nicht, mit welcher Regel er den Ausdruck auf ein Ergebnis reduzieren soll. Bei der Auswertung der Tokenfolgen im Parser, gibt es generell zwei unterschiedliche Herangehensweisen. Man kann, wie im Beispiel, die Ausdrücke 14 direkt auswerten. Das funktioniert bei Beispiel-Projekten kleineren Umfangs recht gut, ist aber bei komplexeren Aufgaben nicht üblich. Die bessere und elegantere Form der Auswertung ist es, vom Parser einen Syntaxbaum erstellen zu lassen. Dafür müssen entsprechende Klassen definiert werden, die den Syntaxbaum abbilden. Bei der Auswertung der Tokens werden dann vom Parser Objekte dieser Token-Klassen erstellt und in den Syntaxbaum eingetragen. Im Kapitel 5 wird ein Projekt erläutert, in dem diese Vorgehensweise angewendet wurde. 4 Demoprojekt Das Ziel meines Projektes ist die Generierung eines Scanners und eines Parsers zur Auswertung arithmetischer Ausdrücke. Auf dieser Grundlage soll schließlich eine grafische Darstellung der eingegebenen arithmetischen Ausdrücke realisiert werden. Um komplexe arithmetische Ausdrücke, das heißt Ausdrücke mit Variablen, auswerten zu können, soll vom Parser eine baumartige Struktur erstellt werden. So kann beispielsweise die unterschiedliche Priorität von Multiplikation und Addition berücksichtigt werden. Abbildung 6: Beispiel für einen Baum 15 Die Erstellung eines Syntaxbaumes wird dadurch erreicht, dass im Action-Code über Result jeweils Instanzen von entsprechenden Klassen zurückgegeben werden. Die Tokenklassen Add, Mult, Div aus dem Beispielbaum (siehe Abbildung 6) sind Teil der Klassenhierarchie, wie sie in Abbildung 7 dargestellt ist. terminal Integer NUMBER; non terminal Expr expr; expr ::= NUMBER:n {: RESULT=new Const(n); :} | expr:l PLUS expr:r {: RESULT=new Add(l,r)); :} | expr:l MINUS expr:r {: RESULT=new Sub(l,r)); :} ; Add und Sub sind Klassen, die von der abstrakten Klasse Expr erben. Der Typ des Nonterminals expr ist Expr. Im Gegensatz zur Vorgehensweise der direkten Auswertung ist mit der Erstellung eines Syntaxbaumes auch der Umgang mit Variablen problemlos möglich. Eine Variable wird über RESULT=new Ident(String i) in den Baum eingetragen. Abbildung 7: Überblick über das Demoprojekt Als Grundlage für eine graphische Darstellung einer Funktion dient die Auswertung des Ausdrucks mit vielen verschiedenen x-Werten. Diese Auswertung findet in der Klasse Evaluator (siehe Abbildung 7) statt, die dafür die Main-Methode des Parsers aufruft und den fertigen Syntaxbaum vom Parser anfordert. Im Evaluator wird außerdem überprüft, ob es sich um eine einfache Berechnung handelt oder ob eine Variable enthalten ist: Pattern p = Pattern.compile(".*?([A-Za-z][A-Za-z0-9]*).*"); String string = expr.intoString(); Matcher m; boolean b = false; 16 m = p.matcher(string); b = m.matches(); Zusammengefasst wird mit dem Demoprojekt für eingegebene arithmetische Ausdrücke eine entsprechende graphische Darstellung erstellt. 5 Schluss Reguläre Ausdrücke in Java werden gern vermieden, da sie schnell zu komplex und fehleranfällig werden können. Es entsteht viel Code, der ungünstig zu ändern und zu warten ist. Die Verwendung von CUP und JFlex ist im Vergleich zu einer Implementierung in Java mit Pattern, Matcher und If-Anweisungen eine wesentlich elegantere Variante. Man erreicht eine vergleichsweise gute Übersichtlichkeit. Nachträgliche Änderungen sind leichter zu ergänzen und für Außenstehende ist die Logik des Programms verständlicher. Literatur [1] Lesson: Regular Expressions, 2008. http://java.sun.com/docs/books/tutorial/essential/regex/. [2] Andrew W. Appel. Modern Compiler Implementation in Java, 1998. [3] Scott E. Hudson. CUP User’s Manual, http://www2.cs.tum.edu/projects/cup/manual.html#intro. 2006. [4] Gerwin Klein. JFlex http://www.jflex.de/manual.html. 2009. User’s Manual, [5] Martin Knobloch. JLex & CUP. http://www2.informatik.huberlin.de/k̃unert/lehre/SS2003-compilergeneratoren/20030619JLex CUP/JLex CUP-short.pdf. [6] Sun Microsystems. Java API, 2003. http://java.sun.com/j2se/1.4.2/docs/api/java/util/regex/Pattern.html. 17