Java für Fortgeschrittene Proseminar im Sommersemester 2009

Werbung
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
Herunterladen