2 Arithmetische Ausdrücke

Werbung
13
Copyright 1996-1998 by Axel T. Schreiner. All Rights Reserved.
2
Arithmetische Ausdrücke
Aus einer geeigneten(!) Grammatik kann man nach der Methode des Rekursiven Abstiegs von
Hand ein Programm entwickeln, das zum Beispiel arithmetische Ausdrücke übersetzen oder
interpretieren kann.
Sehr einfache Sprachen kann man methodisch auch ohne Werkzeuge implementieren —
allerdings läßt sich ein derartiges Programm in der Regel nur mühsam erweitern.
Programmgeneratoren vereinfachen die Arbeit, beeinflussen aber die Architektur der
Implementierung mehr oder weniger stark.
In diesem Abschnitt werden eine Reihe von Techniken und Werkzeugen betrachtet, mit
denen man Compiler und Interpreter implementieren kann. In jedem Fall werden Zeilen von
der Standard-Eingabe gelesen, arithmetische Ausdrücke analysiert und als Bäume gespeichert
und bewertet. Unabhängig von der jeweiligen Technik wird möglichst viel Code, nach
Möglichkeit durch Vererbung, wiederverwendet.
In erster Linie geht es darum, für den Compilerbau wichtige Aspekte der
Java-Programmierung zu wiederholen: Pakete, Sichtbarkeit, einige Core-Klassen,
verschiedene Varianten von Inneren Klassen und Vererbung. Die Benutzung der Werkzeuge
wird zwar erläutert, aber zu Gunsten einer breiten Perspektive von Techniken wird hier auf
eine intensive Diskussion jedes einzelnen Werkzeugs verzichtet.
Themen
2-1
2-2
2-3
2-4
2-5
2-6
2-7
2-8
2-9
2-10
2-11
Syntaxbeschreibungen und Bäume
Rekursiver Abstieg — expr/java
Ein LL(1)-basierter Parser-Generator — expr/javacc
Automatische Baumgenerierung — expr/jjtree
Ein LR(1)-basierter Parser-Generator — expr/jay
Ein Java-basierter LR(1) Parser-Generator — expr/cup
Ein Java-basierter Scanner-Generator — expr/jlex
Ein Parser aus Objekten — expr/oops
Ein Visitor-Generator für Objektbäume — expr/jag
Code-Generierung
Reguläre Ausdrücke — re
14
19
33
40
44
59
66
72
89
100
120
14
Syntaxgraphen
Wirth beschrieb die Syntax von Pascal durch benannte, gerichtete Graphen, in denen blaue,
runde Knoten Eingabesymbole darstellen und schwarze, eckige Knoten auf Graphen
verweisen. Für arithmetische Ausdrücke sieht das etwa so aus:
sum
product
product
term
+
*
-
/
%
term
Number
+
(
sum
)
-
Jeder Graph beschreibt eine Phrase, das heißt, letzlich eine akzeptable Folge von
Eingabesymbolen. Um zu kontrollieren, ob eine Folge akzeptabel ist, durchläuft man den
Graphen und prüft dabei an blauen Knoten die Folge; an schwarzen Knoten muß man andere
Graphen ebenso durchlaufen und prüfen.
Für eine sum muß man also wenigstens ein product finden, danach kann nach plus oder
minus jeweils ein weiteres product folgen. Das product muß aus wenigstens einem term
bestehen, der zum Beispiel eine Number sein kann.
Das Spiel wird problematisch, wenn die runden Knoten keine eindeutigen Wegweiser
darstellen.
15
Grammatikregeln — Backus-Naur-Form (BNF)
Formal besteht eine Grammatik aus einer Menge von Eingabesymbolen, einer Menge von
Grammatikbegriffen, daraus einem Startbegriff, und einer Menge von Regeln, das heißt,
bestimmten Paaren von Folgen von Grammatikbegriffen und Eingabesymbolen; alle Mengen
und Folgen müssen endlich sein.
Typischerweise schreibt man nur die Regeln auf und verlangt bei kontextfreien Grammatiken,
daß die linke Seite einer Regel immer ein Grammatikbegriff sein muß. Nach Konvention steht
der Startbegriff auf der linken Seite der ersten Regel und man faßt rechte Seiten zum gleichen
Grammatikbegriff als Alternativen zusammen. Die Wiederholungen der Syntaxgraphen muß
man durch rekursive Verweise modellieren. Für arithmetische Ausdrücke sieht das etwa so
aus:
sum
: product | sum ’+’ product | sum ’-’ product ;
product : term | product ’*’ term | product ’/’ term | product ’%’ term ;
term
: ’+’ term | ’-’ term | ’(’ sum ’)’ | Number ;
trennt linke und rechte Seite, | trennt Alternativen, ; steht nach allen rechten Seiten zum
gleichen Grammatikbegriff. Eingabesymbole werden mit einfachen Anführungszeichen zitiert.
Da Number nicht links vorkommt, muß Number (implizit) eine Klasse von Eingabesymbolen
repräsentieren. Man könnte auch folgende Regeln hinzufügen:
:
Number
digit
: digit | Number digit ;
: ’0’ | ’1’ | ’2’ | ’3’ | ’4’ | ’5’ | ’6’ | ’7’ | ’8’ | ’9’ ;
16
Erweiterte Backus-Naur-Form (EBNF)
Syntaxgraphen korrespondieren wesentlich intuitiver zu Regeln, wenn man sich auf
Schreibweisen für Wiederholungen und relativ standardisierte Graphen einigt, zum Beispiel
one
alt
two
alt : ( one | two ) ;
( )
|
zur Zusammenfassung
für Alternativen
{ }
einmal oder mehrfach
[ ]
höchstens einmal
some
body
some : { body } ;
opt
body
opt : [ body ] ;
many
body
many : [{ body }] ;
kombiniert: beliebig oft
Analog zu den Syntaxgraphen sehen arithmetische Ausdrücke dann etwa so aus:
sum
: product [{ (’+’|’-’) product }];
product : term [{ (’*’|’/’|’%’) term }];
term
: [{’+’|’-’}] ( Number | ’(’ sum ’)’ ) ;
Leere Alternativen
Leere Alternativen sind in EBNF überflüssig — sie werden nur noch mit opt oder many
ausgedrückt.
17
Syntax-Baum
Eine kontextfreie Grammatik erzeugt orientierte Bäume: Ein Grammatikbegriff ist ein Knoten
und eine seiner rechten Seiten legt fest, wohin der Knoten verzweigt. Aus der Regel
term
:
’+’ term | ’-’ term
| ’(’ sum ’)’ | Number ;
können zum Beispiel folgende Bäume entstehen:
term
+
term
term
-
term
term
term
sum
(
Number
)
Prinzipiell funktioniert das auch mit EBNF. Aus
term
: [{’+’|’-’}] ( Number | ’(’ sum ’)’ ) ;
können folgende Bäume entstehen, allerdings mit beliebigen Graden in manchen Knoten:
term
+
-
...
term
Number
+
-
...
(
sum
)
Eine Folge von Eingabesymbolen bildet einen Satz zu einer Grammatik, wenn es einen Baum
gibt, dessen Wurzel der Startbegriff und dessen Blätter, von links nach rechts, die
Eingabesymbole in Folge sind.
Die Grammatik heißt mehrdeutig, wenn zu irgendeinem Satz verschiedene derartige Bäume
konstruiert werden können.
18
Interpreter-Baum
Für praktische Zwecke interessant sind vereinfachte Bäume, bei denen die Knoten manche
Eingabesymbole repräsentieren und sich die Verzweigungen am Syntaxbaum orientieren. Aus
term
term
term
+
term
-
term
(
sum
term
Number
)
werden zum Beispiel
-
term
term
Number
sum
wobei allerdings die grünen Knoten noch durch Bäume ersetzt werden müssen. Aus
sum
sum
-
product
product
term
term
Number
-
Number
Number
Number
wird dann nur
Die vereinfachten Bäume enthalten alle Informationen, die zur Bewertung nötig sind. Da sie
aus den Syntaxbäumen hergeleitet werden, enthalten die Syntaxbäume auch Bedeutung, wie
zum Beispiel Vorrang, und deshalb kann Mehrdeutigkeit bei einer Grammatik nicht akzpetiert
werden.
19
Rekursiver Abstieg — expr/java
Expression liest Zeilen mit arithmetischen Ausdrücken von der Standard-Eingabe, codiert sie
als Bäume und bewertet sie mit float-Arithmetik. Ist als Argument -c angegeben, wird ein
Vector aller Bäume zur Standard-Ausgabe geschrieben. Dieser Vector kann mit Go
eingelesen und in allen arithmetischen Typen bewertet werden:
$ cd expr/java; make 2 | make 3
CLASSPATH=../.. java expr.java.Expression -c
CLASSPATH=../.. java expr.java.Go
2+3
32768 * 2147483648 + 128
4 * 9223372036854775807
4 * 9.2
byte
short
int
long
float
double
5
5
5
5
5.0
5.0
-128
128
128
70368744177792 7.0368744E13
7.0368744177792E13
-4
-4
-4
-4
3.6893488E19
3.6893488147419103E19
36
36
36
36
36.8
36.8
Jedes makefile enthält die Ziele 1 bis 6 . 2 liest von der Standard-Eingabe und schreibt
Bäume zur Standard-Ausgabe, 3 bewertet derartige Bäume. Einige der Beispiele sind so
gewählt, daß man erkennt, wie die Wertebereiche je nach Typ der Bewertung verlassen
werden.
Expression
demonstriert den Umgang mit StreamTokenizer zur Analyse eines Texts, die
Programmierung eines Parsers mit der Methode des Rekursiven Abstiegs, und die Ausgabe
von persistenten Objekten.
ist eine Unterklasse von StreamTokenizer, die zum Beispiel Kommentare
standardisiert und ein Problem bei interaktiver Benutzung umgeht. Scanner wird in den
anderen Beispielen wiederverwendet, wenn keine Programmgeneratoren zur lexikalischen
Analyse zum Einsatz kommen.
Scanner
Die Bäume werden aus Node-Objekten konstruiert. Node erweitert Number und erlaubt
damit auch den Einsatz von Ausdrücken an Stelle von Konstanten. Node ist das Laufzeitsystem
für alle Beispiele in diesem Abschnitt.
zeigt schließlich, wie man persistente Objekte wieder einliest und verwendet. Auch Go
wird für alle Beispiele in diesem Abschnitt verwendet.
Go
Expression beruht auf einem Beispiel aus dem Java-Skript , das allerdings nicht für
Wiederverwendung konzipiert war. Ein Vergleich der beiden Implementierungen ist recht
instruktiv.
20
Analyse mit Rekursivem Abstieg — expr/java/Expression.java
Aus (geeigneten) Syntaxgraphen kann man sofort Erkenner-Funktionen konstruieren:
sum
product
+
{expr/java/Expression.java sum}
/** recognizes sum: product [{ (’+’|’-’) product }];
@param s source of first input symbol, advanced beyond sum.
@return tree with evaluators.
@see Expression#line
*/
public static Number sum (Scanner s) throws Exception, IOException {
Number result = product(s);
for (;;)
switch (s.ttype) {
case ’+’:
s.nextToken();
result = new Node.Add(result, product(s));
continue;
case ’-’:
s.nextToken();
result = new Node.Sub(result, product(s));
continue;
default:
return result;
}
}
{}
liefert Eingabesymbole, hier zumeist Zeichen; das nächste Symbol steht nach
Scanner
Aufruf von nextToken() in ttype zur Verfügung . Die Funktionen müssen verbrauchte
Symbole entsorgen.
In Node sind Klassen verschachtelt, aus denen Bäume gebaut werden können. Alle diese
Klassen sind Unterklassen von Number .
Es lohnt sich, grundsätzlich Kommentare für javadoc anzulegen . Leider unterstützt erst
javadoc im JDK 1.2 die Inneren Klassen und die Architektur wurde zwischen beta3 und beta4
sehr stark modifiziert.
21
product
term
*
/
%
{expr/java/Expression.java product}
/** recognizes product: term [{ (’*’|’%’|’/’) term }];
@param s source of first input symbol, advanced beyond product.
@return tree with evaluators.
@see Expression#sum
*/
public static Number product (Scanner s) throws Exception, IOException {
Number result = term(s);
for (;;)
switch (s.ttype) {
case ’*’:
s.nextToken();
result = new Node.Mul(result, term(s));
continue;
case ’/’:
s.nextToken();
result = new Node.Div(result, term(s));
continue;
case ’%’:
s.nextToken();
result = new Node.Mod(result, term(s));
continue;
default:
return result;
}
}
{}
Methoden sind static, denn der Parser hat keinen zentralen Zustand — wie zum Beispiel
Die
eine Symboltabelle oder globalen Zugriff auf den Scanner.
22
term
Number
+
(
sum
)
{expr/java/Expression.java term}
/** recognizes term: ’+’term | ’-’term | ’(’sum’)’ | Number;
@param s source of first input symbol, advanced beyond term.
@return tree with evaluators.
@see Expression#sum
*/
public static Number term (Scanner s) throws Exception, IOException {
switch (s.ttype) {
case ’+’:
s.nextToken();
return term(s);
case ’-’:
s.nextToken();
return new Node.Minus(term(s));
case ’(’:
s.nextToken();
Number result = sum(s);
if (s.ttype != ’)’) throw new Exception("expecting )");
s.nextToken();
return result;
case s.TT_WORD:
result = s.sval.indexOf(".") < 0 ? (Number)new Long(s.sval)
: (Number)new Double(s.sval);
s.nextToken(); return result;
}
throw new Exception("missing term");
}
}
// end of class Expression
{}
Das Verfahren wird als Rekursiver Abstieg bezeichnet, weil sich die Erkennungsfunktionen in
der Regel rekursiv aufrufen, und weil sie den Syntax-Baum von der Wurzel (sum) hin zu den
Blättern (term) aufbauen.
23
beschreibt keine Eingabezeile, denn die muß mit einem Zeilentrenner abgeschlossen sein,
der in term natürlich nichts zu suchen hat. Es ist ganz praktisch, wenn man Zeilen auch noch in
einer eigenen Methode erkennt:
sum
{expr/java/Expression.java line}
/** recognizes line: sum ’\n’;
an empty line is silently ignored.
@param s source of first input symbol, may be at end of file.
@return tree for sum, null if only end of file is found.
@throws Exception for syntax error.
@throws IOException discovered on s.
*/
public static Number line (Scanner s) throws Exception, IOException {
for (;;)
switch (s.nextToken()) {
default:
Number result = sum(s);
if (s.ttype != s.TT_EOL) throw new Exception("expecting nl");
return result;
case s.TT_EOL:
continue;
// ignore empty line
case s.TT_EOF:
return null;
}
}
{}
Zur Erkennung einer Zeile wird ein Symbol vorausgelesen. Anschließend muß nach einer sum
definitiv ein Zeilentrenner gefunden werden. Er wird durch den nächsten Aufruf von line()
entsorgt (Stilbruch!).
Leere Zeilen werden stillschweigend ignoriert.
Zum Schluß liefert line() keinen Baum mehr sondern null.
Fehler werden mit einer eigenen Unterklasse von Exception berichtet, damit sie separat
abgefangen werden können. Diese Klasse ist static, denn sie ist nur verschachtelt in
Expression, ihre Objekte beziehen sich nicht auf Expression-Objekte.
{expr/java/Expression.java Exception}
/** indicates parsing errors.
*/
public static class Exception extends java.lang.Exception {
public Exception (String msg) {
super(msg);
}
}
{}
24
{expr/java/Expression.java}
package expr.java;
import
import
import
import
java.io.InputStreamReader;
java.io.IOException;
java.io.ObjectOutputStream;
java.util.Vector;
/** recognizes, stores, and evaluates arithmetic expressions.
*/
public abstract class Expression {
/** reads lines from standard input, parses, and evaluates them
or writes them as a Vector to standard output if -c is set.
@param args if -c is specified, a Vector is written.
*/
public static void main (String args []) {
boolean cflag = args.length > 0 && args[0].equals("-c");
Vector lines = cflag ? new Vector() : null;
Scanner scanner = new Scanner(new InputStreamReader(System.in));
try {
do
try {
Number n = Expression.line(scanner);
if (n != null)
if (cflag) lines.addElement(n);
else System.out.println(n.floatValue());
} catch (java.lang.Exception e) {
System.err.println(scanner +": "+ e);
while (scanner.ttype != scanner.TT_EOL
&& scanner.nextToken() != scanner.TT_EOF)
;
}
while (scanner.ttype == scanner.TT_EOL);
if (cflag) {
ObjectOutputStream out = new ObjectOutputStream(System.out);
out.writeObject(lines);
out.close();
}
} catch (IOException ioe) { System.err.println(ioe); }
}
{}
25
Das Hauptprogramm erzeugt einen Scanner für die Standard-Eingabe und eventuell einen
Vector , um Bäume zu speichern. line() liefert jeweils einen Baum, der dann entweder
gespeichert oder bewertet und ausgedruckt wird.
Zur Ausgabe von Objekten dient ein ObjectOutputStream , da Vector und alle Baumknoten
als Serializable definiert sind. Hier muß unbedingt close() aufgerufen werden, um den
letzten Puffer auszugeben.
Nach Fehlern positioniert man den Scanner explizit auf das nächste Zeilenende; bei
sollte man jedoch unbedingt abbrechen — auch wenn man gerade nach einem
IOException
Zeilentrenner sucht.
Scanner ist eine
für toString()
Unterklasse von StreamTokenizer und liefert deshalb eine Positionsangabe
, die sich gut für Fehlermeldungen eignet.
ist eine abstrakte Klasse, da man sinnvollerweise keine Objekte anlegen sollte.
Trotzdem kann man die static vereinbarten Methoden aufrufen.
Expression
Um Kontrolle über die Sichtbarkeit von Namen zu behalten, sollte man grundsätzlich package
verwenden — der dort angegebene Paket-Pfad muß mit dem Dateipfad ausgehend von einer
Komponente von CLASSPATH bis zum Quellverzeichnis übereinstimmen oder vollständig in
einem Archiv liegen, das auf dem CLASSPATH angegeben ist. Die Ziele 4 bis 6 im makefile
zeigen, wie man mit derart archivierten Klassen arbeitet.
Außerdem sollte man bei import kein Muster angeben — damit ist aus der vorliegenden
Quelle schon klar, woher alle Klassennamen stammen müssen. Verschachtelte Klassen wie
Node.Add werden durch import von Node geliefert, aber auch aus ihrem Namen ist klar, wo
man sie zu suchen hat.
26
Umgang mit StreamTokenizer — expr/java/Scanner.java
sollte auf einen Reader aufgesetzt werden, liest dann Bytes(!) und gibt
Zeichen im Bereich 0..255 als Symbole durch nextToken() und in ttype ab. Da ein
StreamTokenizer intern immer ein Byte vorausliest, eignet sich die Klasse für interaktiven
Betrieb nur, wenn man, wie Scanner, diesen Aspekt durch Einfügen künstlicher Zeichen an
einem Zeilentrenner umgeht.
StreamTokenizer
Was unter einem Symbol zu verstehen ist, kann durch Definition verschiedener
Zeichenklassen beeinflußt werden:
commentChar
ordinaryChars
quoteChar
whitespaceChars
parseNumbers
wordChars
leitet einen Kommentar bis zum Zeilenende ein. Außerdem können
und slashSlashComments() verlangt
slashStarComments()
werden.
werden einzeln als Resultat von nextToken() und in ttype geliefert.
leitet einen String bis zum Partner oder Zeilen- oder Datei-Ende ein,
wird als Resultat und in ttype geliefert, mit dem String in sval;
Ersatzdarstellungen wie \a, \b, \f, \n, \r, \t, \v und \ooo werden
umgewandelt.
werden ignoriert — ein Zeilentrenner kann mit eolIsSignificant()
als TT_EOL angefordert werden.
damit werden Ziffern, minus(!) und Punkt(!) zur Fortsetzung von
wordChars, und wenn ein Wort wie eine Gleitkommazahl(!)
aussieht, wird sein Wert in double nval abgelegt und TT_NUMBER
geliefert.
leiten ein Wort ein, das mit Ziffern fortgesetzt werden kann, wenn
diese mit parseNumbers definiert wurden; als Resultat und in ttype
wird TT_WORD geliefert, mit dem Wort in sval.
Da die Voreinstellungen für StreamTokenizer merkwürdig sind, sollte man bei Konstruktion
aufrufen und dann die Zeichenklassen selbst festlegen.
resetSyntax()
konstruiert Voreinstellungen, die für die hier betrachteten arithmetischen Ausdrücke
sinnvoll sind.
Scanner
27
{expr/java/Scanner.java}
package expr.java;
import
import
import
import
import
java.io.BufferedReader;
java.io.FilterReader;
java.io.IOException;
java.io.Reader;
java.io.StreamTokenizer;
/** lexical analyzer for arithmetic expressions.
Comments extend from # to end of line.
Words are composed of digits and decimal point(s).
White space consists of control characters and space and is ignored;
however, end of line is returned.
Fixes the lookahead problem for TT_EOL.
*/
public class Scanner extends StreamTokenizer {
/** kludge: pushes an anonymous Reader which inserts
a space after each newline.
*/
public Scanner (Reader r) {
super (new FilterReader(new BufferedReader(r)) {
protected boolean addSpace;
// kludge to add space after \n
public int read () throws IOException {
int ch = addSpace ? ’ ’ : in.read();
addSpace = ch == ’\n’;
return ch;
}
});
resetSyntax();
commentChar(’#’);
// comments from # to end-of-line
wordChars(’0’, ’9’);
// parse decimal numbers as words
wordChars(’.’, ’.’);
whitespaceChars(0, ’ ’);
// ignore control-* and space
eolIsSignificant(true);
// need ’\n’
}
}
{}
hätte
eleganter
wiederverwendbar
definiert
werden können, aber die Klasse
StreamTokenizer
eignet sich auch so durchaus zur Zerlegung typischer Programmiersprachen.
Muß man ganze und dezimale Zahlen unterscheiden, muß man wohl wie hier Zahlen als
Worte erkennen und später nacharbeiten:
case s.TT_WORD:
result = s.sval.indexOf(".") < 0 ? (Number)new Long(s.sval)
: (Number)new Double(s.sval);
28
Bäume für arithmetische Ausdrücke — expr/java/Node.java
Das Laufzeitsystem soll Zahlenwerte speichern und in nachträglich wählbaren, beliebigen
Typen, byte bis double, manipulieren. Es bietet sich an, Baumknoten von Number abzuleiten
und alle Methoden wie intValue() usw. so zu implementieren, daß bei Bedarf die
Unterbäume bewertet und dann arithmetisch kombiniert werden.
Node
long
ist die Basisklasse aller Baumknoten. Zur Vereinfachung wird hier die Arithmetik nur auf
und double abgebildet:
package expr.java;
import java.io.Serializable;
/** base class to store and evaluate arithmetic expressions.
Defines most value-functions so that subclasses need only deal
with long and double arithmetic.
*/
public abstract class Node extends Number implements Serializable {
/** maps byte arithmetic to long.
@return truncated long value.
*/
public byte byteValue () {
return (byte)longValue();
}
[In Node.java muß relativ viel Text für verschiedene Operationen bzw. Datentypen repliziert
werden, deshalb werden hier nur einzelne Methoden gezeigt.]
wird als Serializable vereinbart, damit Bäume über Object-Streams transportiert
werden können.
Node
29
/** represents a binary operator.
Must be subclassed to provide evaluation.
*/
protected abstract static class Binary extends Node {
/** left operand subtree.
@serial left operand subtree.
*/
protected Number left;
/** right operand subtree.
@serial right operand subtree.
*/
protected Number right;
/** builds a node with two subtrees.
@param left left subtree.
@param right right subtree.
*/
protected Binary (Number left, Number right) {
this.left = left; this.right = right;
}
}
und analog Unary sind die Basisklassen für binäre bzw. monadische Operationen. Der
Konstruktor speichert die Unterbäume und erzwingt dadurch in abgeleiteten Klassen die
korrekte Struktur. In diesen Klassen könnte man auch in toString() eine Darstellung des
Laufzeitbaums implementieren.
Binary
und right sind nicht transient markiert und werden folglich serialisiert. javadoc im JDK
1.2 verlangt, daß die tatsächlich serialisierten Felder explizit kommentiert werden, und
produziert daraus einen Bericht über die persistenten Daten.
left
Das Laufzeitsystem geht davon aus, daß der Compiler korrekten Code generiert; deshalb wird
zum Beispiel nicht verifiziert, daß die Unterbäume wirklich existieren.
30
/** implements addition.
*/
public static class Add extends Binary {
/** builds a node with two subtrees.
@param left left subtree.
@param right right subtree.
*/
public Add (Number left, Number right) {
super(left, right);
}
/** implements long addition.
@return sum of subtree values.
*/
public long longValue () {
return left.longValue() + right.longValue();
}
/** implements double addition.
@return sum of subtree values.
*/
public double doubleValue () {
return left.doubleValue() + right.doubleValue();
}
}
Für Operatoren müssen dann nur noch die arithmetischen Methoden für die verschiedenen
Datentypen implementiert werden, die entsprechend auf die Werte der Unterbäume zugreifen
können.
Da das ganze System auf Number aufbaut, kann man Literale direkt als Long
(oder beliebige andere Unterklassen von Number) repräsentieren.
oder Double
ist ein typischer, völlig polymorpher Interpreter: Literale werden so gespeichert, daß sie
Werte beliebigen Typs abliefern; Operatoren kombinieren Werte in beliebigen Typen. Go
demonstriert, daß man zur Laufzeit den Datentyp wählen und dann noch verschiedene
Resultate bekommen kann.
Node
Der Vorteil eines derartigen Laufzeitsystems ist natürlich, daß beim Übersetzen keine
Semantikprüfung nötig ist. Der Nachteil ist, daß die Polymorphie relativ teuer erkauft wird und
praktisch nur in Interpretern zur Verfügung steht. Prinzipiell könnte man bei einer
Codegenerierung Datentypen wählen und hart binden.
31
Objekte einlesen und bearbeiten — expr/java/Go.java
Go liest einen Vector mit Bäumen für arithmetische Ausdrücke aus der Standard-Eingabe und
bewertet sie in verschiedenen Typen.
{expr/java/Go.java}
package expr.java;
import java.io.ObjectInputStream;
import java.util.Vector;
/** executes arithmetic expressions from standard input.
*/
public class Go {
/** loads a vector with Number elements from standard input
and evaluates them.
@param args ignored
*/
public static void main (String args []) {
try {
ObjectInputStream in = new ObjectInputStream(System.in);
Vector lines = (Vector)in.readObject();
System.out.println("byte\tshort\tint\tlong\tfloat\tdouble");
for (int i = 0; i < lines.size(); ++ i) {
Number n = (Number)lines.elementAt(i);
System.out.println(n.byteValue()+"\t"+n.shortValue()
+"\t"+n.intValue()+"\t"+n.longValue()
+"\t"+n.floatValue()+"\t"+n.doubleValue());
}
} catch (Exception e) { System.err.println(e); }
}
}
{}
Persistente Objekte werden mit einem ObjectOutputStream geschrieben und mit einem
wieder eingelesen. Der Stream enthält nur die Objekt-Daten; die
ObjectInputStream
Klassen müssen beim Einlesen vorhanden oder über den CLASSPATH oder einen
ClassLoader erreichbar sein, sonst gibt es die üblichen Exceptions.
kann ein Objekt nur liefern, wenn seine tatsächliche Klasse gefunden wird.
Das Resultat wird man normalerweise umwandeln.
readObject()
Liest man einen ObjectInputStream bis zum Ende, muß man eine EOFException
abfangen.
32
Fazit
eignet sich zur schnellen Konstruktion von Scannern, wenn die
Konventionen passen. Das Beispiel demonstriert, daß leider eine Unterscheidung von ganzen
und Gleitkomma-Zahlen mühsam ist; außerdem stoßen Operatoren auf Schwierigkeiten, die
aus mehreren Zeichen bestehen.
StreamTokenizer
Rekursiver Abstieg eignet sich zur schnellen Konstruktion von Parsern ohne Werkzeuge, wenn
man überblickt, daß sich die Grammatik dazu eignet. Die resultierenden Parser sind nicht
unbedingt pflegefreundlich.
Gegenbeispiel zum Rekursiven Abstieg ist zum Beispiel Linksrekursion:
sum : product | sum ’+’ product | sum ’-’ product ;
In diesem Fall würde ein naiver Erkenner in eine rekursive Schleife gehen. Rechtsrekursion
erzeugt zwar einen Baum, interpretiert Operatoren aber rechts-assoziativ und eignet sich
daher kaum für arithmetische Ausdrücke.
Ein weiteres Gegenbeispiel:
output : ’write’ sum [{ ’,’ sum }] [ ’,’ ] ’;’ ;
Ein nachfolgendes Komma soll bedeuten, daß kein Zeilentrenner ausgegeben wird. Hier
überblickt der Rekursive Abstieg nicht, ob nach einem Komma eine sum gesucht werden soll,
oder ob ein Semikolon die Anweisung abschließt. Zur Lösung würde man zwei
Eingabesymbole vorausschauen müssen.
Serialisierung ist eine elegante Technik, einen Interpreter betriebsbereit zu speichern oder
Daten zum Beispiel von einem Compiler zu einem Analyseprogramm oder Generator zu
übermitteln. Arbeitet man sorgfältig, kann man die Analyse (Expression) vollständig vom
Laufzeitsystem (Node) trennen.
33
Ein LL(1)-basierter Parser-Generator — expr/javacc
Die Methode des Rekursiven Abstiegs führt nahezu automatisch von einem geeigneten
Syntaxgraphen zu einem Parser. Der Gedanke liegt nahe, diesen Vorgang zu automatisieren,
das heißt, EBNF an Stelle von Kontrollstrukturen in Parser-Funktionen zu verwenden. In
diesem Abschnitt wird der Umgang mit JavaCC skizziert, einem in Java implementierten
Parser-Generator, der auf der Basis von EBNF Funktionen zum Rekursiven Abstieg generiert.
Mehr Details zu JavaCC kann man in der zugehörigen Dokumentation und im Java-Skript
finden.
Ob sich ein Syntaxgraph oder eine in EBNF formulierte Grammatik für das Verfahren eignen,
hängt davon ab, ob man an jeder Verzweigung eindeutig, nur auf der Basis von den nächsten
Eingabesymbolen, entscheiden kann, mit welchem Teil der Grammatik die Erkennung
fortgesetzt werden muß. Backtracking ist zwar theoretisch möglich, praktisch aber sehr
ineffizient und kaum gangbar, da man die Konsequenzen einer Erkennung in der Regel nicht
mehr rückgängig machen kann.
sum : product [{ (’+’|’-’) product }];
Ob man nach dem ersten product nochmals nach product sucht, hängt davon ab, ob als
Eingabesymbol + oder - angeboten wird. Wenn allerdings auch noch eine Regel wie
cat : sum [{ ’+’ sum }];
zur Grammatik gehört, könnte man nach dem product bei + nicht entscheiden, ob man sum
oder product suchen muß. Intuitiver ist, daß man bei
output : ’write’ expr [{ ’,’ expr }] [ ’,’ ] ’;’ ;
vermutlich zwei Eingabesymbole kennen muß, um bei einem Komma entscheiden zu können,
ob noch eine expr benötigt wird.
Nach Knuth spricht man von LL(k): Vorgehen von links nach rechts, Ableitung von links (von
der Wurzel des Parse-Baums) her, also top-down, mit k Eingabesymbolen Vorausschau.
Später wird gezeigt , wie man berechnen kann, ob eine Grammatik LL(1) ist; bei JavaCC
kann man lokal auch mit LL(k) und k > 1 arbeiten.
Ein Parser-Generator ist immer auch ein Prüfprogramm: JavaCC entscheidet, ob eine
Grammatik LL(k) ist - normalerweise mit k = 1. Ist eine Grammatik LL(k), so ist sie nicht
mehrdeutig.
34
Prinzip
JavaCC liest eine Quelle und erzeugt normalerweise eine Parser-Klasse, einen Scanner und
eine Reihe von Hilfsklassen. Die Quelle enthält in der Regel nacheinander: eine Gruppe von
Optionen zur Steuerung der Übersetzung ; Java-Code, der mindestens die Parser-Klasse
definieren muß ; Regeln mit regulären Ausdrücken, die mindestens die uninteressanten
Eingabezeichen definieren ; und schließlich die Parser-Methoden:
void sum():
{ }
{
product()
( "+" product()
| "-" product()
)*
}
Grammatikbegriff als Funktionskopf
lokale Variablen, siehe unten
rechte Seite als Funktionskörper
Grammatikbegriff als Funktionsaufruf
Eingabesymbol als String (oder TOKEN-Name)
| für Alternativen
( ... )* für beliebig viele Wiederholungen
Schon hier ist die Syntax problematisch, da zum Beispiel die Klammern teilweise zu EBNF
gehören und teilweise Funktionsaufrufe markieren.
Für die Funktionen können beliebige Resultattypen und Parameter sowie throws mit
Exceptions vereinbart werden, wobei jedoch nur der Funktionsname als Grammatikbegriff
signifikant ist — wenigstens für JavaCC selbst.
Ablaufverfolgung
Mit der Option -debug_parser generiert JavaCC eine sehr detaillierte Ablaufverfolgung.
35
Aktionen
In der bisher skizzierten Form der Regeln kann man — mit relativ hohem Schreibaufwand —
eine Grammatik prüfen. Das Ziel ist jedoch, mit einem erkannten Satz etwas anzufangen, also
mindestens, einen Parse-Baum zu konstruieren. Dazu verwendet man lokale Variablen,
Resultate der Parser-Funktionen sowie Aktionen — Java-Code in geschweiften Klammern, der
im Zuge der Erkennung ausgeführt wird:
Number sum():
{ Number s, r; }
{ s = product()
( "+" r = product()
| "-" r = product()
)*
}
{expr/javacc/Expression.jj rules}
// sum: product [{ (’+’|’-’) product }];
// returns tree
{ s = new Node.Add(s, r); }
{ s = new Node.Sub(s, r); }
{ return s; }
Number product():
{ Number p, r;}
{ p = term()
( "*" r = term()
| "%" r = term()
| "/" r = term()
)*
}
// product: term [{ (’*’|’%’|’/’) term }];
// returns value
Number term():
{ Number t; }
{ "+" t = term()
| "-" t = term()
| "(" t = sum() ")"
| <LONG>
| <DOUBLE>
}
// term: ’+’term | ’-’term | ’(’sum’)’ | Number;
// returns value
{ return t; }
{ return new Node.Minus(t); }
{ return t; }
{ return new Long(token.image); }
{ return new Double(token.image); }
{
{
{
{
p = new Node.Mul(p, r); }
p = new Node.Mod(p, r); }
p = new Node.Div(p, r); }
return p; }
{}
Funktionsresultate müssen durch return in Aktionen erzeugt werden. Beim Aufrufer können
sie dann an lokale Variablen als Teil der Grammatikbegriffe (Funktionsaufrufe) innerhalb der
rechten Seite einer Regel zugewiesen werden. Die lokalen Variablen stehen innerhalb der
Aktionen ebenfalls zur Verfügung.
36
Eingabesymbole
Angaben wie <LONG> beziehen sich auf benannte reguläre Ausdrücke, die typischerweise vor
den Regeln in der Quelle stehen:
SKIP:
{ " " | "\r" | "\t"
| < "#" (~ ["\n"])* >
}
{expr/javacc/Expression.jj inputs}
// defines input to be ignored
// comment from # to end of line
// re: many of negated character class
TOKEN:
// defines token names
{ < EOL: "\n" >
| < LONG: ( <DIGIT> )+ >
// re: some
| < DOUBLE: ( <DIGIT> )+ "." ( <DIGIT> )*
// re: some many
| "." ( <DIGIT> )+ >
| < #DIGIT: ["0" - "9"] >
// private re
}
{}
Im SKIP-Block stehen Muster für Folgen von Eingabezeichen, die ignoriert werden sollen.
Muster für Kommentare kann man entweder hier oder in SPECIAL_TOKEN-Blocks vereinbaren;
im letzteren Fall sind sie über eine Komponente .specialToken in Aktionen erreichbar.
Im TOKEN-Block können Muster wie LONG benannt werden, für die in der Parser-Klasse dann
Konstanten definiert sind; für DIGIT gibt es im vorliegenden Fall keine Konstante.
Funktionalität und zumeist auch Syntax der Muster orientieren sich an egrep, wobei allerdings
Literale grundsätzlich als Strings angegeben werden müssen. Insgesamt entsteht auch hier
ein kurioses Mix mehrdeutig verwendeter Metazeichen wie | für Alternativen im Block und im
Muster.
37
Fehlerbehandlung
Wenn ein Eingabesymbol nicht paßt, erzeugt ein JavaCC-Parser eine ParseException, die
letztlich den äußersten Funktionsaufruf zu Fall bringen würde. ParseException ist eine
Unterklasse von Exception; eine entsprechende throws-Klausel wird implizit zu den
Parse-Methoden hinzugefügt.
Um frühzeitig auf Eingabefehler zu reagieren, kann man im EBNF-Bereich der Regeln
(Funktionen) try-Blöcke einsetzen, die allerdings explizit generierte Exceptions bisher nicht
auffangen können. Bei Bedarf kann der Funktionskopf eine throws-Klausel enthalten.
{expr/javacc/Expression.jj rules}
Number line():
// line: sum ’\n’;
{ Number e; }
// returns tree, null at eof
{ try {
( e = sum() <EOL> { return e; }
| <EOL>
{ return line(); }
| <EOF>
{ return null; }
)
} catch (ParseException err) {
System.err.println(err);
for (;;)
switch (getNextToken().kind) {
case EOF:
return null;
case EOL:
return line();
}
}
}
{}
Auch hier entsteht ein kurioses Syntax-Mix: Ein äußerer Block kann Alternativen direkt
enthalten, ein try-Block nicht. Der catch-Bereich des try-Blocks ist eine Aktion; dort werden
die TOKEN-Namen direkt als Konstanten angegeben, während sie im EBNF-Bereich mit < >
zitiert werden müssen.
liefert im Parser vom TokenManager das nächste Eingabesymbol. .kind ist
eine Komponente, die man mit TOKEN-Namen vergleichen sollte.
getNextToken()
Man sieht hier, daß man auch in Aktionen parsieren kann: Die for-Schleife eliminiert den Rest
einer defekten Zeile und versucht, über einen rekursiven Aufruf die nächste korrekte Zeile zu
finden. Diese Technik ist zwar elegant aber nicht pflegefreundlich.
38
Parser-Klasse
Am Anfang der Quelle steht ein Block von Java-Code, der die Parser-Klasse definieren muß, in
die dann die Parser-Funktionen eingefügt werden:
{expr/javacc/Expression.jj}
PARSER_BEGIN(Expression)
package expr.javacc;
import
import
import
import
import
java.io.InputStreamReader;
java.io.IOException;
java.io.ObjectOutputStream;
java.util.Vector;
expr.java.Node;
/** recognizes, stores, and evaluates arithmetic expressions
using a parser generated with javacc.
*/
public class Expression {
/** reads lines from standard input, parses, and evaluates them.
or writes them as a Vector to standard output if -c is set.
@param args if -c is specified, a Vector is written.
*/
public static void main (String args []) {
boolean cflag = args.length > 0 && args[0].equals("-c");
Vector lines = cflag ? new Vector() : null;
Expression parser = new Expression(new InputStreamReader(System.in));
try {
Number n = null;
do {
n = parser.line();
if (n != null)
if (cflag) lines.addElement(n);
else System.out.println(n.floatValue());
} while (n != null);
if (cflag) {
ObjectOutputStream out = new ObjectOutputStream(System.out);
out.writeObject(lines);
out.close();
}
} catch (IOException ioe) { System.err.println(ioe); }
catch (ParseException pe) { }
// cannot happen
}
}
PARSER_END(Expression)
{}
Konstruktoren werden implizit für InputStream und Reader als Argumente erzeugt; JavaCC ist
nicht mehr deprecated.
Das Startsymbol der Grammatik spielt keine besondere Rolle - jede Parser-Funktion kann
explizit aufgerufen werden.
39
Fazit
JavaCC ist vollständig in Java implementiert und wird kommerziell entwickelt und gepflegt. Da
bei Bedarf lokal LL(k) verwendet werden kann, sind die Erkenner sehr mächtig. Da alle
Funktionen separat aufgerufen werden können, kann man auch verschiedene Teile eines
Parsers nebeneinander in der gleichen Applikation einsetzen.
Prinzipiell könnte das System auch zur Generierung von Scannern herangezogen werden,
allerdings kenne ich die Schnittstelle zum TokenManager nicht genau genug. Die Muster zur
Generierung von Scannern sind wesentlich mächtiger als etwa StreamTokenizer.
Ich finde die Syntax (noch) nicht gelungen: Durch die Vermischung von EBNF und Java-Code in
Form von Funktionsköpfen, -aufrufen, Zuweisungen und try-Blöcken entsteht eine sehr
unübersichtliche Repräsentierung der Grammatik — erst JJDoc fördert die eigentliche
Grammatik wieder zutage, wenigstens, soweit sie außerhalb der Aktionen sichtbar ist:
line
sum
product
term
:=
:=
:=
:=
|
|
|
|
( sum <EOL> | <EOL> | <EOF> )
product ( "+" product | "-" product )*
term ( "*" term | "%" term | "/" term )*
"+" term
"-" term
"(" sum ")"
<LONG>
<DOUBLE>
Auch die Syntax für Muster ist gewöhnungsbedürftig. Bei SKIP dürfen Strings durch | als
Alternativen angegeben werden, komplexere Muster (mit weiteren Alternativen) müssen aber
in < > stehen.
bildet Syntaxfehler konsequent auf einen Java-Mechanismus ab. Leider ist
aber die Implementierung (derzeit) so, daß andere Exceptions verdeckt werden können. Es
hilft nicht, daß try-Blöcke in der Syntaxbeschreibung von JavaCC (derzeit) nicht erwähnt sind.
ParseException
JavaCC erzeugt viele Klassen und Interfaces. Wenigstens einige davon sollten meines
Erachtens innere Klassen sein. Speziell bei der Fehlerbehandlung muß man unangenehm viele
Interna kennen.
40
Automatische Baumgenerierung — expr/jjtree
Für JavaCC gibt es einen Präprozessor JJTree, der die Generierung von Parse-Bäumen
weitgehend automatisieren soll. Hier wird nur in Ausschnitten skizziert, wie man das
vorhergehende Beispiel erweitert, um in den Genuß von JJTree zu kommen. Mehr Details
kann man in der zugehörigen Dokumentation und im Java-Skript finden.
Prinzip
Bei vielen Phrasen gibt man an Stelle einer Aktion einen Klassennamen und die Anzahl
Abkömmlinge an, die der Baumknoten enthalten soll:
void sum():
{}
{ product()
( "+" product() #Add(2)
| "-" product() #Sub(2)
)*
}
// sum: product [{ (’+’|’-’) product }];
// Add with 2 descendants
Parallel zum Aufruf der Methode existiert ein Stack, auf dem die erzeugten Knoten abgelegt
werden. #Add(2) bedeutet, daß zwei Knoten vom Stack zu einem Add-Knoten verknüpft
werden, der dann neu auf den Stack gelegt wird. Eingabesymbole erzeugen implizit keine
Knoten.
Zum Schluß würden alle noch auf dem Stack befindlichen Knoten zu einem Knoten für den
Aufruf der Methode zusammengefaßt werden; der Klassenname richtet sich nach der
Methode, kann aber überschrieben werden. Die folgenden Optionen verhindern dies:
options {
MULTI = true;
NODE_DEFAULT_VOID = true;
NODE_PREFIX = "";
VISITOR = true;
}
//
//
//
//
use many class names, not just SimpleNode
only generate explicitly requested nodes
don’t prefix them with AST
create Visitor interface
Kombiniert man Aktionen und Dekoration mit Klassennamen, kann man die Knoten explizit
manipulieren:
void term():
// term: ’+’term | ’-’term | ’(’sum’)’ | Number;
{}
{ "+" term()
// no need to make node
| "-" term() #Minus
// insert sign change node
| "(" sum() ")"
| ( <LONG> { jjtThis.val = new Long(token.image); } ) #Lit
// needs .val
| ( <DOUBLE> { jjtThis.val = new Double(token.image); } ) #Lit
}
Gibt man keine Anzahl an, werden alle derzeit lokal vorhandenen Knoten zusammengefaßt,
bei #Minus also der von term() produzierte Knoten, bei #Lit kein Knoten. jjtThis bezieht sich
auf den offenen Knoten, in dem bei Lit in einer eigens hinzugefügten Komponente .val der
Zahlenwert gespeichert wird.
41
Feinheiten
Klassennamen für Knoten werden an Phrasen angehängt. Die Phrase ist damit der
Geltungsbereich für jjtThis. Innerhalb des Geltungsbereichs sind die Abkömmlinge noch
nicht im Knoten eingetragen und nur sehr mühsam erreichbar.
Die letzte Aktion im Geltungsbereich weicht davon ab: für sie ist der Knoten komplett. Das
kann man zum Beispiel dazu mißbrauchen, von line() im Regelfall den Parse-Baum zu liefern:
expr.jjtree.Node line() #Add:
// line: sum \n
{}
// returns null at eof
{ try {
hier ist der Knoten noch offen
( sum() <EOL>
| <EOL>
{ return line(); }
| <EOF>
{ return null; }
hier auch noch
)
} catch (ParseException err) {
System.err.println(err);
for (;;)
switch (getNextToken().kind) {
case EOF:
return null;
case EOL:
return line();
}
erst jetzt folgt syntaktisch die letzte Aktion
}
{ return jjtThis.jjtGetChild(0); } nur der Abkömmling wird zum Resultat
}
JJTree generiert die Knoten-Klassen. Sie stammen alle von SimpleNode ab, einer
‘‘einfachen’’ Implementierung für das an sich verlangte Interface Node .
Hier wird ein temporärer Knoten erzeugt, nur damit man ihm dann den für sum() konstruierten
Abkömmling entnehmen kann. Der Knoten könnte eine SimpleNode sein, aber dann generiert
JJTree leider eine Visitor-Methode doppelt.
Die Lösung dient hier mehr zur Illustration. Den Parse-Baum kann man auch als .rootNode()
dem in der Parser-Klasse Expression als jjtree vorhandenen JJTExpressionState
entnehmen.
42
Visitor
Wirklich elegant ist (wenigstens im Prinzip), wie man einen mit JJTree konstruierten Baum
verarbeiten soll: Per Option VISITOR kann ein Interface ExpressionVisitor erzeugt werden,
das für jede Knoten-Klasse eine Methode enthält. Man kann das Interface so implementieren,
daß ein Parse-Baum traversiert wird, wobei jeder Knoten genau einmal einem
ExpressionVisitor-Objekt geliefert wird.
Hier bietet sich an, damit zum Parse-Baum einen Interpreter-Baum aus den früher
betrachteten Node-Objekten zu konstruieren:
protected static class Gen implements ExpressionVisitor {
public Object visit(Add node, Object data) {
return new Node.Add(
(Number)node.jjtGetChild(0).jjtAccept(this, data),
(Number)node.jjtGetChild(1).jjtAccept(this, data));
}
...
public Object visit(Lit node, Object data) {
return node.val;
}
}
könnte im Rahmen der Traverse von der Wurzel zu den Blättern des Parse-Baums
durchgereicht werden.
data
Die Visitor-Schnittstelle forciert eine sehr saubere Trennung zwischen Parse-Baum und
Interpreter-Baum: zur Analyse dient ein Baum auf der Basis von SimpleNode und erst mit
einem Visitor wird ein persistenter Baum auf der Basis von expr.java.Node erzeugt.
43
Hauptprogramm
Das Hauptprogramm ändert sich wenig gegenüber dem Hauptprogramm für einen nur von
JavaCC generierten Parser. Bevor jedoch ein neuer Baum gebaut werden kann, muß reset()
aufgerufen werden:
public static void main (String args []) {
boolean cflag = args.length > 0 && args[0].equals("-c");
Vector lines = cflag ? new Vector() : null;
Expression parser = new Expression(new InputStreamReader(System.in));
ExpressionVisitor gen = new Gen();
try {
for (;; jjtree.reset()) {
expr.jjtree.Node node = parser.line();
if (node == null) break;
Number n = (Number)node.jjtAccept(gen, null);
if (cflag) lines.addElement(n);
else System.out.println(n.floatValue());
}
if (cflag) {
ObjectOutputStream out = new ObjectOutputStream(System.out);
out.writeObject(lines);
out.close();
}
} catch (IOException ioe) { System.err.println(ioe); }
catch (ParseException pe) { }
}
Fazit
Zusammen mit JavaCC wird auch JJTree kommerziell in Java entwickelt und gepflegt. Die
Visitor-Schnittstelle ist sehr elegant und ohne Werkzeug kaum zuverlässig zu beherrschen.
Man kann Visitor problemlos mehrmals implementieren und damit zum Beispiel Code für
verschiedene Architekturen erzeugen.
Demgegenüber sind Syntax und Konventionen zur Manipulation der Knoten während der
Konstruktion in der Regel nur durch Studium des generierten Codes zu erahnen. Insgesamt
überwiegen für mich diese Probleme, denn die Konstruktion eines Baums an sich ist auch
ohne Präprozessor eher trivial.
44
Ein LR(1)-basierter Parser-Generator — expr/jay
Prinzip
Auf Knuth geht eine Technik zur Parser-Erzeugung zurück, die Horning in einem Artikel in
Compiler Construction, An Advanced Course (ISBN ??) sehr gut erklärt hat und die in
Generatoren wie jay und cup implementiert ist:
extra:
sum EndOfInput;
^
Zur Grammatik nimmt man eine Regel mit dem Startsymbol hinzu und markiert den Punkt vor
dem Startsymbol. Eine markierte Regel heißt Konfiguration. Diese erste Konfiguration führt
zum Ausgangszustand des Parsers; zum Schluß möchte man EndOfInput erreichen.
sum: ^ product;
sum: ^ product ’+’ sum;
sum: ^ product ’-’ sum;
product: ^ term;
product: ^ term ’*’ product;
product: ^ term ’%’ product;
product: ^ term ’/’ product;
term: ^ ’+’ term;
term: ^ ’-’ term;
term: ^ ’(’ sum ’)’;
term: ^ Number;
Da die Markierung vor einem Grammatikbegriff steht, nimmt man (transitiv) dessen rechte
Seiten hinzu und markiert jeweils den Anfang. Eine Menge von Konfigurationen ist ein
Zustand.
45
Neue Zustände ergeben sich, indem man in jeder Konfiguration einzeln die Markierung um ein
Symbol verschiebt und, falls nötig, wieder rechte Seiten hinzunimmt. Die Menge der Zustände
ist endlich, gleiche Zustände werden natürlich zusammengefaßt. Insgesamt entsteht eine
Matrix aus Zuständen und Übergängen, die durch Symbole ausgelöst werden.
term: Number ^;
Erreicht die Markierung das Ende einer Regel, nennt man die Konfiguration komplett. Ist sie in
einem Zustand zusammen mit anderen Konfigurationen, liegt ein Konflikt vor. Dies kann oft
entschärft werden, wenn man die Konfigurationen noch um Lookahead-Mengen erweitert,
siehe Horning.
Die Matrix kann man a priori berechnen und dabei die Grammatik prüfen. Eingabesymbole
verursachen dann Übergänge; die Zustände legt man dabei auf einen Stack. Erreicht man eine
komplette Konfiguration, wurde offensichtlich eine Regel erkannt: Man entfernt genügend
Zustände vom Stack und führt einen Übergang mit dem zugehörigen Grammatikbegriff durch.
state 0
$accept : . line $end
line : . (2)
Constant shift 1
’+’ shift 2
’-’ shift 3
’(’ shift 4
$end reduce 2
line goto 5
sum goto 6
product goto 7
term goto 8
(0)
46
yacc und bison
yacc [Johnson, 1978] und bison [Corbett und Stallman] akzeptieren jeweils eine Tabelle von
Grammatikregeln in BNF und Aktionen und konstruieren eine Funktion zur Analyse einer
Symbolfolge:
Wenn ein Stück der Eingabe der rechten Seite einer Regel genügt, wird die zugehörige
Aktion ausgeführt, und die Teilfolge wird durch den Grammatikbegriff links ersetzt.
Regeln wirken als Muster, können sich aber praktisch gegenseitig aufrufen. Aktionen können
für yacc und bison in C und Dialekten wie Objective C oder C++ formuliert werden.
yacc und bison sind Prüfprogramme: Eine Grammatik wird auf Einhaltung bestimmter
Bedingungen (LALR(1), impliziert nicht-Mehrdeutigkeit) kontrolliert.
yacc und bison sind Programmgeneratoren: Regeln dienen als Kontrollstruktur zur Auswahl
von Aktionen. bison ist aufwärtskompatibel zu yacc.
Aus einer Tabelle ohne Anweisungen wird ein Programm erzeugt, das mit einem Scanner
bereits feststellen kann, ob eine Symbolfolge einer Grammatik genügt.
jay
jay entstand durch Modifikation der yacc-Quellen von BSD-Lite . jay übernimmt den
Algorithmus von yacc, generiert jedoch ein Java-Programm, das optional eine verbesserte
Ablaufverfolgung enthält. Aktionen werden in Java formuliert.
jay arbeitet als Filter für ein Programmskelett , das bedingt übersetzt wird. Prinzipiell könnte
man dieses Skelett auch abändern, um Schnittstellen und Paketierung zu beeinflussen.
47
Eingabe
Eine jay- oder yacc-Quelle besteht aus drei Teilen, die durch Zeilen mit %% getrennt sind.
Im ersten Teil müssen unter anderem die Eingabesymbole benannt werden, damit dafür
Zahlenwerte definiert werden können. Einzelne(!) Zeichen können direkt zitiert werden.
Außerdem wird der Typ der Objekte auf dem Wert-Stack festgelegt.
Im zweiten Teil stehen die Grammatikregeln in BNF und Aktionen in Java bzw. C. Aktionen
können auch eingebettet sein, dadurch entstehen anonyme Grammatikbegriffe.
{expr/jay/Expression.jay rules}
%token
%type
%%
<Number>
<Number>
Constant
line, sum, product, term
line
: sum
| /* null */
// $$ = $1
{ $$ = null; }
sum
: product
| sum ’+’ product
| sum ’-’ product
// $$ = $1
{ $$ = new Node.Add($1, $3); }
{ $$ = new Node.Sub($1, $3); }
product :
|
|
|
term
product ’*’ term
product ’/’ term
product ’%’ term
// $$ = $1
{ $$ = new Node.Mul($1, $3); }
{ $$ = new Node.Div($1, $3); }
{ $$ = new Node.Mod($1, $3); }
term
’+’ term
’-’ term
’(’ sum ’)’
Constant
{ $$ = $2; }
{ $$ = new Node.Minus($2); }
{ $$ = $2; }
// $$ = $1
{}
:
|
|
|
In den Aktionen bezieht sich $$ auf einen Wert, der parallel zum Grammatikbegriff auf den
Zustandsstack gebracht wird, und $i auf Werte, die für die Symbole der rechten Seite auf
dem Stack sind. Ist keine Aktion angegeben, gilt $$ = $1, das heißt, der erste Wert wird
übernommen. Man ahnt, wie hier wieder ein Interpreter-Baum konstruiert wird.
48
Funktionsweise
yacc generiert einen Push-Down-Automaten: Eingabesymbol und Zustand auf dem Stack
definieren die Operation des Automaten.
Zeichenfolge
Scanner
Eingabesymbol
Wert
•
•
Zustand
•
•
Operation
Mit der Option -v generiert yacc in der Datei y.output
eine Beschreibung des PDA.
shift zustand
Eingabesymbol wird akzeptiert, neuer Zustand wird dafür auf dem Stack
abgelegt.
reduce regel
Stack enthält Phrase (Folge von Zuständen), die durch Grammatikbegriff
ersetzt werden kann – der Syntaxbaum wächst. Entsprechend viele
Zustände werden entfernt.
goto zustand
Ein von reduce erzeugter Grammatikbegriff wird akzeptiert, der neue
Zustand wird dafür auf dem Stack abgelegt.
yacc berechnet vor allem die Übergangsmatrix – das geht nicht immer!
Konsequenzen
Zu jedem Symbol einer Regel korrespondiert immer ein Platz auf dem Syntax-Stack und
parallel dazu auf dem Wert-Stack.
Auf dem Syntax-Stack liegen Zustände, auf den Wert-Stack kann man selbst Werte bringen:
Bei yacc kann der Scanner in yylval den Wert ablegen, der durch shift auf den
Wert-Stack wandert, bei jay ist das das Resultat von value() in yyInput.
Bei reduce, also am Schluß einer erkannten Regel, findet die Aktion statt, und die kann
auf den Wert-Stack mit $i und auf das goto-Resultat mit $$ zugreifen.
Der Typ aller Elemente des Wert-Stacks kann definiert werden (bei yacc ist int voreingestellt,
bei jay wird zunächst Object verwendet).
49
Scanner
Im dritten Teil der Grammatik-Quelle kann nahezu beliebiger Java- bzw. C-Code stehen. Dort
kann man bei jay zum Beispiel das Interface yyInput implementieren, von dem der Parser
seine Eingabe erwartet (yacc benötigt eine Funktion yylex()):
{expr/jay/Expression.jay scanner}
%%
/** lexical analyzer for arithmetic expressions.
@see expr.java.Scanner
*/
public static class Scanner extends expr.java.Scanner implements yyInput {
public Scanner (Reader r) {
super(r);
}
/** moves to next input token.
Consumes end of line and pretends (once) that it is end of file.
@return false at end of file and once at each end of line.
*/
public boolean advance () throws IOException {
if (ttype != TT_EOF) nextToken();
return ttype != TT_EOF && ttype != TT_EOL;
}
/** determines current input, sets value to Long or Double for Constant.
@return Constant or token’s character value, 0 at end of line/file.
*/
public int token () {
value = null;
switch (ttype) {
case TT_EOF:
return 0;
// should not happen
case TT_EOL:
return 0;
// should not happen
case TT_WORD:
value = sval.indexOf(".") < 0 ? (Number)new Long(sval)
: (Number)new Double(sval);
return Constant;
default:
return ttype;
}
}
/** value associated with current input.
*/
protected Object value;
/** produces value associated with current input.
@return value.
*/
public Object value () {
return value;
}
}
{}
50
Die Parser-Klasse
yacc erzeugt eine Funktion yyparse(), die bei Erfolg 0 liefert. jay muß eine Java-Quelle, also
eine Klassendefinition erzeugen. Diese beginnt in einem Code-Abschnitt in %{ %} im ersten
Teil der Grammatik-Quelle und endet mit } im dritten Teil:
{expr/jay/Expression.jay}
%{
package expr.jay;
import
import
import
import
import
import
java.io.InputStreamReader;
java.io.IOException;
java.io.Reader;
java.io.ObjectOutputStream;
java.util.Vector;
expr.java.Node;
/** recognizes, stores, and evaluates arithmetic expressions
using a parser generated with jay.
*/
public class Expression {
/** reads lines from standard input, parses, and evaluates them
or writes them as a Vector to standard output if -c is set.
@param args if -c is specified, a Vector is written.
*/
public static void main (String args []) {
boolean cflag = args.length > 0 && args[0].equals("-c");
Vector lines = cflag ? new Vector() : null;
Scanner scanner = new Scanner(new InputStreamReader(System.in));
Expression parser = new Expression();
try {
while (scanner.ttype != scanner.TT_EOF)
try {
Number n = (Number)parser.yyparse(scanner);
if (n != null)
if (cflag) lines.addElement(n);
else System.out.println(n.floatValue());
} catch (yyException ye) { System.err.println(scanner+": "+ye); }
if (cflag) {
ObjectOutputStream out = new ObjectOutputStream(System.out);
out.writeObject(lines);
out.close();
}
} catch (IOException ioe) { System.err.println(ioe); }
}
%}
{expr/jay/Expression.jay zzz}
}
{}
51
Man muß mindestens die Klasse anlegen, in der jay die Methode yyparse(), einige innere
Klassen und Methoden sowie int-Konstanten für die definierten Eingabesymbole erzeugt :
public class Expression {
public static final int Constant = 257;
/** thrown for irrecoverable syntax errors and stack overflow.
*/
public static class yyException extends java.lang.Exception {
...
}
/** must be implemented by a scanner object to supply input to the parser.
*/
public interface yyInput {
/** move on to next token.
@return false if positioned beyond tokens.
@throws IOException on input error.
*/
boolean advance () throws java.io.IOException;
/** classifies current token.
Should not be called if advance() returned false.
@return current %token or single character.
*/
int token ();
/** associated with current token.
Should not be called if advance() returned false.
@return value for token().
*/
Object value ();
}
/** simplified error message.
@see yyerror(String, String[])
*/
public void yyerror (String message) {
yyerror(message, null);
}
/** (syntax) error message.
Can be overwritten to control message format.
@param message text to be displayed.
@param expected vector of acceptable tokens, if available.
*/
public void yyerror (String message, String[] expected) {
...
}
/** kludge: fixed maximum size of the state/value stack.
This is not final so that it can be overwritten outside of invocations
of yyparse().
*/
protected int yyMax = 256;
/** executed at the beginning of a reduce action.
Used as $$ = yyDefault($1), prior to the user-specified action, if any.
Can be overwritten to provide deep copy, etc.
@param first value for $1, or null.
@return first.
*/
protected Object yyDefault (Object first) {
return first;
}
52
/** the generated parser, with debugging messages.
Maintains a state and a value stack, currently with fixed maximum size.
@param yyLex scanner.
@param yydebug debug message writer implementing yyDebug, or null.
@return result of the last reduction, if any.
@throws yyException on irrecoverable parse error.
*/
public Object yyparse (yyInput yyLex, Object yydebug)
throws java.io.IOException, yyException {
...
}
/** the generated parser.
Maintains a state and a value stack, currently with fixed maximum size.
@param yyLex scanner.
@return result of the last reduction, if any.
@throws yyException on irrecoverable parse error.
*/
public Object yyparse (yyInput yyLex)
throws java.io.IOException, yyException {
...
}
...
}
benötigt ein yyInput-Objekt, von dem die Eingabesymbole abgeholt werden, und
liefert als Resultat den Wert, den die letzte Aktion erzeugt; yyInput ist eigentlich von der
Parser-Klasse unabhängig.
yyparse()
Der Wert-Stack enthält bei jay in jedem Fall Object. Mit %type und %token kann man
speziellere Klassen für Eingabesymbole und Grammatikbegriffe vereinbaren, die als $i
angesprochen werden :
%type
<A>
%token <A>
%%
nonterminal
nonterminal
nonterminal
nonterminal
nonterminal
terminal
:
:
:
:
terminal { $$ = $i; }
terminal
terminal { $$ = $<>i; }
terminal { $$ = $<B>i; }
weist Klasse A an Object zu
führt bei jay yyDefault() aus
weist Object an Object zu
weist B an Object zu
Bei yacc beziehen sich die <...> auf Alternativen einer union als Wert-Stack, die mit %union
vereinbart wird.
53
Struktogramm des Parsers
Das folgende ‘‘Struktogramm’’ [Schreiner, unix/mail 3/91] skizziert die Architektur des von
yacc generierten Parsers und zeigt, wo Ablaufverfolgung und Fehlerbehandlung eingreifen:
yyanim_init
yystack:
yyanim_push
yynewstate:
yylex()
Operation aus Tabelle:
-- yyerrflag (bis 0)
shift
yyanim_shift state
goto yystack
accept
return 0
yyerrflag
0
yyerror()
yyerrflag = 3
error
error
verwendbar
yyanim_shift error
goto yystack
yyanim_pop
1,2
bis Stack leer
return 1
if eof return 1
3
reduce
yyanim_discard
goto yynewstate
yyanim_reduce rule, length
if ... yyanim_goto
{ Aktion }
goto yystack
54
Vorrang
Vor allem arithmetische Ausdrücke können mehrdeutig spezifiziert und durch eine Tabelle mit
Assoziativität und zeilenweise steigendem Vorrang eindeutig gemacht werden:
%left
’+’ ’-’
%right ’^’
%nonassoc DUMMY
%%
expr : expr ’+’ expr
| expr ’^’ expr
| ’-’ expr %prec DUMMY
links-assoziativ
rechts-assoziativ
nicht assoziativ, z.B. Vergleiche
expliziter Vorrang
shift/reduce-Konflikt
Enthält ein Zustand ‘‘auch’’ eine komplette Konfiguration, kann der Parser vor einem
Eingabesymbol einen Grammatikbegriff reduzieren oder das Eingabesymbol noch
hinzunehmen :
statement
: IF condition THEN statement
| IF condition THEN statement ELSE statement
yacc bildet die längste Phrase — erwünscht!
if (a < b)
then
if (c < d)
then
...
else
gehört zum innersten if – das längste statement ist gesucht.
reduce/reduce-Konflikt
Enthält ein Zustand mehrere komplette Konfigurationen, kann der Parser vor einem
Eingabesymbol mehrere Grammatikbegriffe reduzieren :
statement
: Variable ’=’ condition
| Variable ’=’ expression
condition
: expression
| expression ’<’ expression
yacc bildet die erste Phrase — selten erwünscht!
55
Animation
Mit der Option -t generiert jay einen Parser, dem man mit yyparse() zusätzlich ein Objekt
übergeben kann, das für das Interface jay.yydebug.yyDebug eine Ablaufverfolgung
implementieren muß. jay.yydebug.yyDebugAdapter realisiert das als Diagnose-Ausgabe,
jay.yydebug.yyAnim
ist ein Frame , der die Ablaufverfolgung in Anlehnung an Holub
grafisch darstellt; bei der Konstruktion kann mit den Bits IN und OUT eine TextArea
eingeblendet werden, die als Terminal für Ein- und/oder Ausgabe dient:
Drückt man continue, fährt der Parser jeweils fort, bis eine Ausgabe in eine Fläche erfolgt ist,
deren Checkbox gesetzt ist.
Manche Implementierungen der JVM blockieren, wenn sie auf Eingabe vom Terminal warten;
in diesem Fall muß man die Animation unbedingt mit der TextArea betreiben.
enthält ein yyAnimPanel , an das yyDebug-Aufrufe weitergeleitet werden, und
eventuell eine TextArea samt Checkbox . Falls verlangt, wird ein yyInputStream als
eingetragen, der dann als System.in hinterlegt wird , und ein yyPrintStream
KeyListener
, der alle Ausgaben in die TextArea umlenkt, wird als System.out und System.err hinterlegt.
Zusätzliche Threads sind dabei nicht nötig, da Eingaben im Event-Thread erfolgen und der
Parser seine Ausgaben im main-Thread tätigt, wodurch auch die Animation bei Ausgabe durch
Synchronisation dieser Threads angehalten werden kann.
yyAnim
Die Bausteine eignen sich auch für ein unsigniertes Applet; dort muß man allerdings einen
eigenen Thread für den Parser verwalten und den Scanner direkt mit den Streams verbinden.
56
Reaktion auf Eingabefehler
Paßt ein Eingabesymbol nicht mehr zur angefangenen Phrase auf dem Stack, und kann davor
auch nicht reduziert werden, liegt ein Eingabefehler vor.
stellt jetzt das fiktive Eingabesymbol error in die Eingabe und ruft yyerror() auf.
jay erzeugt einen Parser, der dabei nach Möglichkeit einen Vektor mit den akzeptablen
Eingabesymbolen als Strings übergibt.
yyparse()
Falls nötig wird der Stack abgeräumt, bis error zum Stack paßt, oder bis der Stack leer ist.
Im ersten Fall geht die Erkennung mit error wie mit einem normalen Symbol weiter. Im
zweiten Fall endet yyparse() in Java mit yyException oder liefert in C nicht Null.
Erst wenn nach error drei echte Symbole durch shift verarbeitet wurden, wird yyerror()
bei einem neuen Fehler wieder aufgerufen.
Durch die Aktion yyErrorFlag = 0; in Java beziehungsweise yyerrok; in C spiegelt man das
vor — es gibt eine Endlosschleife, wenn diese Aktion direkt zu error gehört!
Prinzipien zur Fehlerbehandlung
Man muß zusätzliche Regeln verwenden, bei denen error an plausiblen Stellen steht.
Solche Regeln sollten möglichst nahe am Startsymbol stehen — als Notbremse.
Solche Regeln sollten möglichst weit weg vom Startsymbol stehen — schnelle Erholung.
In der Praxis macht man wichtige Wiederholungen so robust wie irgend möglich. Details siehe
Schreiner/Friedman Compiler bauen mit UNIX .
57
Beispiele zur Fehlerbehandlung
Recover liest Zeilen mit verschiedenen Folgen und demonstriert Fehlerbehandlung bei
typischen Wiederholungen. Durch Ablaufverfolgung kann man kontrollieren, ob Wörter
ignoriert werden.
%%
line
:
|
|
|
// null
line OPT opt ’\n’
line SEQ seq ’\n’
line LIST list ’\n’
{ yyErrorFlag=0; System.out.println("opt"); }
{ yyErrorFlag=0; System.out.println("seq"); }
{ yyErrorFlag=0; System.out.println("list"); }
Optionale Folge
Recover akzeptiert Zeilen, die opt und eine Folge von null oder mehr Wörtern enthalten; ein
Fehler wäre zum Beispiel ein Komma:
opt
: // null
| opt WORD
| opt error
{ yyErrorFlag = 0; }
Folge
Recover akzeptiert Zeilen, die seq und eine Folge von ein oder mehr Wörtern enthalten; Fehler
wären zum Beispiel kein Wort oder ein Komma:
seq
:
|
|
|
WORD
seq WORD
error
seq error
{ yyErrorFlag = 0; }
Liste
Recover akzeptiert Zeilen, die list und eine Folge von ein oder mehr Wörtern enthalten, die
durch Komma getrennt sind; Fehler wären zum Beispiel kein Wort oder kein Komma:
list
:
|
|
|
|
|
WORD
list ’,’ WORD
error
list error
list error WORD
list ’,’ error
{ yyErrorFlag = 0; }
{ yyErrorFlag = 0; }
Als Fehlerbehandlung wird error überall dort eingefügt, wo ein Eingabesymbol stehen könnte.
Genau nach einem Eingabesymbol wird yyErrorFlag = 0; verwendet. Man kann ausprobieren,
daß alle Alternativen nötig sind.
58
Fazit
yacc und seine Abkömmlinge haben sehr einfache Konventionen und sind mächtiger als die
LL(1)-Parser. Die Syntax für Regeln und Aktionen ist trivial zu erlernen. yacc erlaubt
Mehrdeutigkeiten und kann Vorrangtabellen benutzen; dies vereinfacht die Konstruktion von
Grammatiken und macht die Parser effizienter.
jay ist in C implementiert; die Quellen dürfen weitergegeben werden. jay kann zwar auf vielen
Plattformen übersetzt werden, ist aber weniger portabel als eine neue Implementierung in
Java wie cup. Die Modifikationen für jay verursachten jedoch kaum Aufwand; es lohnte sich,
die seit vielen Jahren bewährte Implementierung der Algorithmen einfach zu übernehmen.
[Corbett implementiert allerdings %nonassoc anders als Johnson. Eine triviale Vorrangtabelle
mit einer einzigen Zeile mit %nonassoc bleibt wirkungslos.] Das derzeitige skeleton
verwendet einen Stack fixer Länge.
yacc und bison können nur mühsam mehr als einen Parser in einem Programm zulassen. jay
verpackt den Parser in eine vom Benutzer definierte Klasse, dadurch bleibt der Namensraum
recht sauber.
In C kann man eine union als Typ des Wert-Stacks verwenden. In Java ist man ganz auf eine
Klasse angewiesen. Das ist nicht so effizient; unter Umständen muß man yyDefault() als
tiefe Kopie implementieren, außerdem kann man auf die triviale Aktion $$ = $1; nicht bauen,
wenn die rechte Seite einer Regel leer ist. Da aber in der Regel wohl ein Baum gebaut wird,
besteht der Wert-Stack aus gleichartigen Objekten und das Fehlen einer union ist irrelevant.
yacc kann den C-Compiler mittels #line dazu zwingen, Fehlermeldungen bezüglich der
Aktionen direkt auf die Grammatik-Quelle zu beziehen; dies ist in Java nicht möglich.
In Java kommt man eleganter zu einer Ablaufverfolgung und Animation als in C.
59
Ein Java-basierter LR(1) Parser-Generator — expr/cup
cup (Constructor of Useful Parsers) ist eine Neuimplementierung von LR(1) in Java,
angelehnt an yacc. Die Syntax bezieht sich direkter auf Java und ist wesentlich formaler. Der
generierte Parser stammt von java_cup.runtime.lr_parser ab, der Scanner muß
java_cup.runtime.Symbol liefern. Die Generierung kann durch sehr viele Phrasen und
Optionen beeinflußt werden, außerdem kann man Methoden der Basisklasse überschreiben.
Die Reihenfolge der Eingabe zu cup ist vorgeschrieben. Wir folgen hier der logischen
Entwicklungsreihenfolge, die ziemlich genau umgekehrt verlaufen dürfte.
Grammatik
Die Regeln stehen am Schluß der Eingabe zu cup:
{expr/cup/Expression.cup rules}
RESULT = new Vector(); :}
l.addElement(s); RESULT = l; :}
RESULT = l; :}
RESULT = l; :}
lines
::= /* null */
| lines:l sum:s NL
| lines:l NL
| lines:l error NL
;
{:
{:
{:
{:
sum
::= product:p
| sum:s ADD product:p
| sum:s SUB product:p
;
{: RESULT = p; :}
{: RESULT = new Node.Add(s, p); :}
{: RESULT = new Node.Sub(s, p); :}
product ::= term:t
| product:p MUL term:t
| product:p DIV term:t
| product:p MOD term:t
;
{:
{:
{:
{:
RESULT
RESULT
RESULT
RESULT
=
=
=
=
t; :}
new Node.Mul(p, t); :}
new Node.Div(p, t); :}
new Node.Mod(p, t); :}
term
{:
{:
{:
{:
RESULT
RESULT
RESULT
RESULT
=
=
=
=
t; :}
new Node.Minus(t); :}
s; :}
c; :}
::= ADD term:t
| SUB term:t
| LPAR sum:s RPAR
| CONSTANT:c
;
{}
In den Aktionen bezieht sich RESULT auf einen Wert, der parallel zum Grammatikbegriff auf den
Zustandsstack gebracht wird; andere Namen werden durch Anbringen an den Symbolen der
Regel vereinbart. Es gibt keine voreingestellte Aktion — das führt zu Nullzeiger-Fehlern.
60
Eingabesymbole, Typen und Vorrang
Vor den Regeln definiert man alle(!) Symbole und Wertklassen und legt eventuell auch
Vorrang und Assoziativität einzelner Eingabesymbole fest:
terminal
terminal
{expr/cup/Expression.cup names}
NL, ADD, SUB, MUL, DIV, MOD, LPAR, RPAR;
Number CONSTANT;
non terminal
non terminal
Number sum, product, term;
Vector lines;
{}
Anders als bei yacc können Eingabesymbole nicht zitiert werden und man kann die
generierten Konstanten nicht beeinflussen. Wird keine Klasse angegeben, gibt es keinen
zugehörigen Wert.
Paket und Parser-Klassen
Am Anfang der Eingabe stehen optional package- und import-Anweisungen sowie zwei
Code-Teile, in denen zusätzliche Komponenten für die Aktionen- und Parser-Klasse definiert
werden können:
{expr/cup/Expression.cup}
package expr.cup;
import
import
import
import
import
import
import
java.io.InputStreamReader;
java.io.IOException;
java.io.ObjectOutputStream;
java.io.Reader;
java.util.Vector;
java_cup.runtime.Symbol;
expr.java.Node;
parser code {:
{}
Name und Abstammung der Parser-Klasse sind vordefiniert; der Name kann aber durch
-parser beim Aufruf von cup festgelegt werden.
61
Scanner
Man kann den Scanner als Teil der Parser-Klasse implementieren:
{expr/cup/Expression.cup lex}
/** methods required from scanner.
*/
public interface Input {
/** recognizes and returns the next complete token.
*/
Symbol token () throws java.io.IOException;
}
/** handcrafted lexical analyzer for arithmetic expressions.
@see expr.java.Scanner
*/
protected static class Scanner extends expr.java.Scanner
implements Input, sym {
public Scanner (Reader r) {
super(r);
}
/** recognizes and returns the next complete token.
*/
public Symbol token () throws java.io.IOException {
if (ttype != TT_EOF)
nextToken();
// not past end of file
switch (ttype) {
case TT_EOF:
return new Symbol(EOF);
case TT_EOL:
return new Symbol(NL);
case TT_WORD:
return new Symbol(CONSTANT,
sval.indexOf(".") < 0 ? (Number)new Long(sval)
: (Number)new Double(sval));
case ’+’:
return new Symbol(ADD);
case ’-’:
return new Symbol(SUB);
case ’*’:
return new Symbol(MUL);
case ’/’:
return new Symbol(DIV);
case ’%’:
return new Symbol(MOD);
case ’(’:
return new Symbol(LPAR);
case ’)’:
return new Symbol(RPAR);
default:
return new Symbol(error);
}
}
}
:};
scan with {:
return scanner.token();
:};
{}
Die Schnittstelle zum Parser besteht in einer Methode scan(), die Symbol liefern muß. Ihr
Körper wird durch die Klausel scan with festgelegt. Hier wurde ein Interface Input eingeführt,
um später einen generierten Scanner leicht einfügen zu können. sym ist generiert und
enthält Konstanten für die Eingabesymbole.
62
Hauptprogramm
Zur Initialisierung muß der Scanner hinterlegt werden, damit ihn scan() finden kann.
{expr/cup/Expression.cup}
/** lexical analyzer.
*/
protected Input scanner;
/** creates a parser.
@param scanner lexical analyzer as required by cup.
*/
public Expression (Input scanner) {
this.scanner = scanner;
}
/** uses a locally handcrafted scanner.
@see Expression#main(java.lang.String[], expr.cup.Expression.Input)
*/
public static void main (String args []) {
main(args, new Scanner(new InputStreamReader(System.in)));
}
/** reads lines from standard input, parses, and evaluates them.
or writes them as a Vector to standard output if -c is set.
@param args if -c is specified, a Vector is written.
@param scanner for reuse with a JLex-generated scanner.
*/
public static void main (String args [], Input scanner) {
boolean cflag = args.length > 0 && args[0].equals("-c");
try {
Symbol s = new Expression(scanner).parse();
if (s != null && s.value != null)
if (cflag) {
ObjectOutputStream out = new ObjectOutputStream(System.out);
out.writeObject(s.value);
out.close();
} else
for (int n = 0; n < ((Vector)s.value).size(); ++ n)
System.out.println(((Number)((Vector)s.value).elementAt(n))
.floatValue());
} catch (Exception e) { System.err.println(scanner+": "+e); }
}
{}
Das Hauptprogramm wurde hier aufgetrennt, damit ein anderer Scanner angegeben werden
kann.
63
Ablaufverfolgung
Ruft man debug_parse() statt parse() auf, sieht man etwa folgendes:
#
#
#
#
#
#
#
#
#
#
#
#
#
#
#
#
#
#
#
#
#
#
#
#
#
#
#
#
#
#
Initializing parser
Current Symbol is #10
Reduce with prod #0 [NT=4, SZ=0]
Goto state #1
Shift under term #10 to state #9
Current token is #3
Reduce with prod #15 [NT=3, SZ=1]
Goto state #7
Reduce with prod #8 [NT=2, SZ=1]
Goto state #2
Reduce with prod #5 [NT=1, SZ=1]
Goto state #3
Shift under term #3 to state #15
Current token is #10
Shift under term #10 to state #9
Current token is #2
Reduce with prod #15 [NT=3, SZ=1]
Goto state #7
Reduce with prod #8 [NT=2, SZ=1]
Goto state #16
Reduce with prod #6 [NT=1, SZ=3]
Goto state #3
Shift under term #2 to state #27
Current token is #0
Reduce with prod #2 [NT=4, SZ=3]
Goto state #1
Shift under term #0 to state #8
Current token is #0
Reduce with prod #1 [NT=0, SZ=2]
Goto state #-1
Das ist höchstens nützlich, wenn man mit der Option -dump die Tabellen darstellen läßt, aber
selbst dann muß man die Nummern der Eingabesymbole in den generierten Quellen suchen.
64
Fehlerbehandlung
Recover liest Zeilen mit verschiedenen Folgen und demonstriert Fehlerbehandlung bei
typischen Wiederholungen. Durch Ablaufverfolgung kann man kontrollieren, ob Wörter
ignoriert werden.
Das Prinzip wird von yacc übernommen: Im Fehlerfall wird nach einer Regel mit error gesucht
und von dort aus weiter parsiert.
Der Algorithmus unterscheidet sich aber subtil vom Algorithmus für yacc: Es muß einen
Übergang mit error geben, error im Lookahead allein reicht nicht aus. Konkret muß die
optionale Folge so umformuliert werden:
opt
::= /* null */
| opt WORD
| error
| opt error
;
Die zusätzliche Alternative provoziert einen shift/reduce-Konflikt, der durch die Option -expect
1 akzeptiert werden muß.
Wird error akzpetiert, versucht der Parser, so viele Eingabesymbole zu erkennen, wie die
Methode error_sync_size() verlangt; erst wenn das gelingt, wird mit diesen Symbolen die
Parsierung fortgesetzt. Man kann die Methode zwar überschreiben, aber nicht so gezielt, wie
das mit yyerrok; bei yacc beziehungsweise yyErrorFlag bei jay möglich ist.
Konkret führt das dazu, daß bei einer Eingabe
opt
opt,
zum Schluß die Parsierung mit einer Exception abbricht:
# Error recovery fails at EOF
Couldn’t repair and continue parse
java.lang.Exception: Can’t recover from previous error(s)
65
Fazit
cup ist in Java implementiert und folglich auf vielen Plattformen sofort verfügbar. Abgesehen
von einer etwas barocken Syntax, reichlichen Einschränkungen zur Anordnung der Eingaben
und vielen Verabredungen im Laufzeitsystem, funktioniert cup praktisch so wie yacc und jay.
Die Quellen sind frei verfügbar; die Homepage zu cup liegt im Bereich von Appels Buch ,
so daß man auf Support hoffen kann.
Die Abhängigkeit von lr_parser und Symbol ist unpraktisch; außerdem generiert cup mehrere
freistehende Klassen — für Parser, Aktionen und Eingabesymbolnummern. Die
Aktionen-Klasse verwendet $ im Namen, das könnte Probleme geben.
Fehlerbehandlung funktioniert (leider nur fast) wie für yacc; man kann die Wiederaufnahme
der Parsierung bei wichtigen Eingabesymbolen schlecht beeinflussen. Insgesamt ist eine
erfolgreiche Fehlerbehandlung deutlich schwieriger.
cup kann Zeichenpositionen in der Eingabe verwalten; damit könnte man sehr präzise
Fehlermeldungen generieren. Die Ablaufverfolgung eignet sich nicht zur Animation.
66
Ein Java-basierter Scanner-Generator — expr/jlex
lex und flex
lex [Lesk, 1978] und flex [Paxson] akzeptieren jeweils eine Tabelle von egrep-artigen Mustern
und C-Anweisungen und konstruieren eine C-Funktion yylex() zur Texterkennung oder
-verarbeitung:
Wenn ein Stück der (Standard-)Eingabe einem Muster genügt, wird die zugehörige
C-Anweisung ausgeführt. Nicht erkannte Teile der Eingabe werden kopiert.
lex und flex sind Programmgeneratoren: Muster dienen als Kontrollstruktur zur Auswahl von
Aktionen. Abgesehen davon, daß flex seine Eingabe puffert, sind die Programme ziemlich
kompatibel.
Beispiel: Zeilennumerierung
{JLex/zb/num.l}
%{
/*
*
*/
%}
C-Text am Anfang
Zeilen numerieren
trennt Abschnitte
%%
\n
^.*$
ECHO;
printf("%d\t%s", yylineno, yytext);
leere Zeilen kopieren
{}
Übersetzung
$ lex -t num.l > num.c
$ cc -o num num.c -ll
$ flex -l -t num.l > num.c
"num.l", line 11: warning, dangerous trailing context
"num.l", line 11: warning, dangerous trailing context
"num.l", line 11: warning, dangerous trailing context
$ cc -o num num.c -L/usr/local/gnu/lib -lfl
Die Bibliothek enthält u.a ein Hauptprogramm, das yylex() einmal aufruft. Die Fehlermeldung
bezieht sich offenbar auf die Verwendung von $.
67
jlex
jlex [Berk 1996] ist eine Neuimplementierung von lex in Java. Die Syntax der Tabelle
unterscheidet sich wenig, aber im Vorspann sind sehr viele Java-ismen zu beachten.
Insgesamt ist das System stärker auf Zusammenarbeit mit einem Parser zugeschnitten.
{JLex/zb/Num.lex}
import java.io.IOException;
Java-Text am Anfang
%%
%public
%class Num
%type
void
%eofval{
return;
%eofval}
%line
%{
Optionen im zweiten Abschnitt
öffentliche Klasse
Klassenname
Resultat der Scanner-Funktion
Aktion bei Dateiende
Zeilen mitzählen (yyline)
Java-Code in der Klasse
public static void main (String args []) {
Num num = new Num(System.in);
try {
num.yylex();
} catch (IOException ioe) { System.err.println(ioe); }
}
%}
%%
Muster im dritten Abschnitt
\n
.*$
{ System.out.println(); }
{ System.out.println((yyline+1)+"\t"+yytext()); }
{}
Das Musterelement ^ zur Erkennung eines Zeilenanfangs fehlt. Zeilen werden ab
Der erkannte Text wird per Methode als String (statt in einem Vektor) geliefert.
0 gezählt.
68
Muster
Für jlex-Muster gelten annähernd die Regeln von egrep:
"abc..."
\n \t \b \f \r
\ooo \xhh
\^C
\x
.
[abd-x...]
[^abd-x...]
$
zitierte Zeichen stellen sich selbst dar
Zeilentrenner, Tab, Backspace, Seitenvorschub, Wagenrücklauf
oktal, hexadezimal
control-Zeichen
ein zitiertes Zeichen
ein beliebiges Zeichen (aber kein Zeilentrenner)
ein Zeichen aus einer Klasse
ein Zeichen nicht aus einer Klasse
Treffer an Zeilenende
x*
x+
x?
null oder mehrmals
ein oder mehrmals
optional: null oder einmal
xy
x|y
(...)
nacheinander
alternativ
Vorrang steuern
{name}
im zweiten Abschnitt definiertes Muster
Der Vorrang der Musteroperationen variiert leider. Wenn eine Menge von Mustern
mehrdeutig ist, hat der längste erkannte Text Vorrang und bei gleicher Länge das erste
Muster.
69
Typische Muster
{JLex/zb/Pat.lex}
import java.io.IOException;
%%
%public
%class Pat
%type
void
%eofval{
return;
%eofval}
%{
public static void main (String args []) {
Pat pat = new Pat(System.in);
try {
pat.yylex();
} catch (IOException ioe) { System.err.println(ioe); }
}
%}
alpha =
alnum =
oct =
dec =
hex =
sign =
exp =
L =
X =
[a-zA-Z_]
[a-zA-Z_0-9]
[0-7]
[0-9]
[0-9a-fA-F]
[+-]?
([eE]{sign}{dec}+)
[lL]
[xX]
Man kann Teilmuster vereinbaren
%%
"/*"([^*]|"*"+[^/*])*"*"+"/"
"{"[^}]*"}"
"(*"([^*]|"*"+[^*)])*"*"+")"
"//".*$
{
{
{
{
System.out.println("C\t"+yytext()); }
System.out.println("Pascal\t"+yytext()); }
System.out.println("Pascal\t"+yytext()); }
System.out.println("C++\t"+yytext()); }
\"([^\"\\\n]|\\.|\\\n)*\"
’([^’\n]|’’)+’
{ System.out.println("C\t"+yytext()); }
{ System.out.println("Pascal\t"+yytext()); }
0{oct}+
0{oct}+{L}
0{X}{hex}+
0{X}{hex}+{L}
{dec}+
{dec}+{L}
{
{
{
{
{
{
System.out.println("int oct\t"+yytext()); }
System.out.println("long oct\t"+yytext()); }
System.out.println("int hex\t"+yytext()); }
System.out.println("long hex\t"+yytext()); }
System.out.println("int\t"+yytext()); }
System.out.println("long\t"+yytext()); }
{dec}+"."{dec}*{exp}?|{dec}*"."{dec}+{exp}?|{dec}+{exp} {
System.out.println("double\t"+yytext()); }
’([^’\\\n]|\\[^0-7\n]|\\[0-7][0-7]?[0-7]?)’ {
System.out.println("char\t"+yytext()); }
{alpha}{alnum}*
{ System.out.println("name\t"+yytext()); }
.|\n
{ }
{}
70
Scanner
Mit jlex kann man sehr leicht Scanner implementieren: Die Muster beschreiben
Eingabesymbole und die Aktionen liefern die relevanten Informationen als Resultat von
yylex(). Zur Markierung einer Fehlerposition sollte man eine Methode wie toString()
implementieren.
{expr/jlex/Scanner.lex}
package expr.jlex;
import expr.cup.sym;
import java_cup.runtime.Symbol;
%%
%public
%class Scanner
%type Symbol
Funktion umbenennen
%function token
%eofval{
return new Symbol(sym.EOF);
%eofval}
comment
space
digit
integer
real
=
=
=
=
=
("#".*)
[\ \t\b\015]+
[0-9]
{digit}+
({digit}+"."{digit}*|{digit}*"."{digit}+)
%%
{space}
{comment}
{integer}
{real}
\n
"+"
"-"
"*"
"/"
"%"
"("
")"
.
{
{
{
{
{
{
{
{
{
{
{
{
{
}
}
return
return
return
return
return
return
return
return
return
return
return
new
new
new
new
new
new
new
new
new
new
new
Symbol(sym.CONSTANT, new Long(yytext())); }
Symbol(sym.CONSTANT, new Double(yytext())); }
Symbol(sym.NL); }
Symbol(sym.ADD); }
Symbol(sym.SUB); }
Symbol(sym.MUL); }
Symbol(sym.DIV); }
Symbol(sym.MOD); }
Symbol(sym.LPAR); }
Symbol(sym.RPAR); }
Symbol(sym.error); }
{}
71
Expression
Im vorhergehenden Abschnitt wurde main() so implementiert, daß man leicht einen anderen
Scanner einfügen kann:
{expr/jlex/Expression.java}
package expr.jlex;
import java.io.InputStreamReader;
import java.io.Reader;
public class Expression extends expr.cup.Expression {
/** uses a scanner built by JLex.
@see expr.cup.Expression#main
*/
public static void main (String args []) {
main(args, new Scanner(new InputStreamReader(System.in)));
}
/** wraps lexical analyzer generated by JLex.
Required because JLex cannot label generated class as implementing Input.
@see expr.cup.Expression
*/
protected static class Scanner extends expr.jlex.Scanner implements Input {
public Scanner (Reader r) {
super(r);
}
}
}
{}
Leider kann man bei jlex nicht angeben, daß ein Scanner ein Interface implementiert.
Fazit
jlex ist in Java implementiert und folglich auf vielen Plattformen sofort verfügbar. Das
Verfahren ist sehr mächtig und nützlich, nicht nur für Scanner sondern auch für Filter, die
zeilenübergreifend arbeiten sollen. jlex kann auch Zeichenpositionen in der Eingabe verwalten.
Die Anpassung für Java benötigt leider eine Vielzahl von Phrasen im zweiten Abschnitt, um
die generierte Klasse zu beeinflussen.
72
Ein Parser aus Objekten — expr/oops
oops ist ein vollständig objekt-orientierter, LL(1)-basierter, im Prinzip sprachunabhängiger
Parser-Generator, den wir [Kühl und Schreiner] zur Zeit entwickeln. Er wird hier skizziert, um
einen Einblick zu geben, wie man einen Parser-Generator überhaupt konstruiert, und um neue
Ideen für ein völlig objekt-orientiertes System vorzustellen. oops hat (zur Zeit) keine
Fehlerbehandlung.
oops wurde von Wirth’s generalparser inspiriert
prüfte.
, der allerdings seine Grammatik nicht
T-Diagramme
Compiler werden heute sehr häufig in ihrer eigenen Sprache implementiert. Von
McKeeman(?) stammt die Idee, Übersetzungsvorgänge als sogenannte T-Diagramme
darzustellen, um das Bootstrapping derartiger Compiler zu illustrieren:
Quelle
Ziel
Imp
übersetzt Quelle in Ziel, geschrieben in Imp.
Sprachimplementierungen werden durch senkrechte Balken symbolisiert:
Imp
Sys
Sprache Imp ist auf Plattform Sys ablauffähig.
73
Einer der bisher betrachteten Compiler wird folgendermaßen übersetzt:
expr
.ser
jay
expr
.ser
expr
Java
jay
Java
.cls
Java
C
.cls
.cls
jay
.ser
.cls
javac
any
C
386
.cls
gcc
UNIX
JVM
any
Einen in einer Untermenge C0 geschriebenen C-Compiler für Windows kann man mit einem
System ? herstellen, auf dem C0 verfügbar ist:
C
C0
.exe
C
C
.exe
.exe
.exe
C0
.exe
386
C0
?
74
Idee
Die bisher betrachteten Compiler entstanden nach folgendem Muster:
Daten
Grammatik
CompilerGenerator
Compiler
InterpreterBaum
Der Compiler bearbeitet dann Daten, die der Grammatik genügen. Ändert man die
Beschriftung etwas, entsteht oops:
Grammatik
EBNF
CompilerGenerator
oops
InterpreterBaum
Wenn man einen Compiler aus EBNF herstellt, muß er aus einer Grammatik, die in EBNF
geschrieben ist, ein Resultat produzieren, nämlich einen Compiler. Vergleicht man beide
Zeichnungen, ist dieser Compiler ein Compiler-Generator, eben oops:
Daten
Grammatik
oops
InterpreterBaum
Resultat
Daten, die der Grammatik genügen, sind dann Programme, das Resultat sind übersetzte
Programme — bei uns vermutlich neue Interpreter-Bäume...
Mit T-Diagrammen stellt man dar, daß das System zum Beispiel mit jay entwickelt werden
und sich dann selbst übersetzen könnte. Es müßte nicht von Java abhängen...
75
oops mit jay entwickeln
CompilerGenerator
EBNF
oops
jay übersetzt eine Grammatik, die EBNF in BNF beschreibt
id
lit
parser
rule
alt
seq
some
opt
, in oops:
%token
%token
%type
%type
%type
%type
%type
%type
%%
<Id>
<Lit>
<Parser>
<Rule>
<Alt>
<Seq>
<Some>
<Opt>
parser
: rule
| parser rule
{ $$ = new Parser($1); }
{ $1.add($2); }
rule
: id ’:’ alt ’;’
{ $$ = new Rule(prefix, defaultClass,
$1, $3.node());
}
alt
: seq
| alt ’|’ seq
{ $$ = new Alt($1.node()); }
{ $1.add($3.node()); }
seq
:
|
|
|
|
|
{
{
{
{
{
{
some
: ’{’ alt ’}’
{ $$ = new Some($2.node()); }
opt
: ’[’ alt ’]’
{ $$ = new Opt($2.node()); }
/* empty */
seq id
seq lit
seq some
seq opt
seq ’(’ alt ’)’
// identifier
// quoted string
$$ = new Seq(); }
$1.add($2); }
$1.add($2); }
$1.add($2.node()); }
$1.add($2.node()); }
$1.add($3.node()); }
76
Eine Grammatik mit oops übersetzen
oops
Grammatik
InterpreterBaum
oops konstruiert für eine in EBNF formulierte Grammatik einen Interpreter, der aus Klassen im
Paket oops.parser besteht:
one
alt
two
verwendet Unterbäume, um eine Alternative
zu erkennen.
Alt
seq
one
two
verwaltet Unterbäume, um nacheinander
eine Folge zu erkennen.
Seq
some
body
verwaltet einen Unterbaum, so daß etwas
wenigstens einmal erkannt werden muß.
Some
opt
body
hat einen Unterbaum, der nicht unbedingt
etwas erkennen muß.
Opt
Die Methode node() sorgt unter anderem dafür, daß bei entsprechenden Verschachtelungen
auch Many erzeugt wird:
many
body
verwaltet einen Unterbaum, so daß etwas
beliebig oft erkannt wird.
Many
enthält verschiedene Tabellen und vor allem einen Vektor mit Rule-Objekten , die
Parser
jeweils zu einem Grammatikbegriff einen Unterbaum enthalten, der erkannt werden muß. Lit
beschreibt ein Eingabesymbol, Id verweist entweder auf einen Grammatikbegriff oder
einen Token , der eine Äquivalenzklasse von Eingabesymbolen repräsentiert.
77
Ein Programm mit einer oops-Grammatik übersetzen
Daten
Grammatik
InterpreterBaum
oops
Resultat
Jeder Knoten im Interpreter-Baum versteht eine Methode parse(), die durch
parse-Nachrichten an die Unterbäume des Knotens im Interpreter-Baum mit Rekursivem
Abstieg arbeitet.
{expr/oops/expr.oops.ebnf}
// arithmetic expressions
lines
sum
sum.add
sum.sub
product
product.mul
product.div
product.mod
term
term.minus
:
:
:
:
:
:
:
:
:
:
[{ [ sum ] "\n" }];
product [{ sum.add | sum.sub }];
"+" product;
"-" product;
term [{ product.mul | product.div | product.mod }];
"*" term;
"/" term;
"%" term;
Number | "+" term | term.minus | "(" sum ")";
"-" term;
{}
public void parse (Scanner scanner, Goal goal) throws IOException
Jeder Rule-Knoten sorgt dabei dafür, daß zu Beginn ein Objekt mit der Klasse seines
Grammatikbegriffs angelegt wird. Dieses Objekt erhält für jedes akzeptierte Symbol eine
shift-Nachricht und am Ende der Regel eine reduce-Nachricht mit dem Interface Goal :
public interface Goal extends Serializable {
void shift (Lit sender);
// quoted string
void shift (Token sender, Object node); // token, node from scanner
void shift (Goal sender, Object node); // nonterminal, node from reduce
Object reduce ();
}
Die Hilfsklassen GoalAdapter und GoalDebugger sind triviale Implementierungen, die
einfach die vom ersten Token oder Goal gelieferte node bei reduce() abliefern.
Implementiert eine Klasse das Interface Reduce oder ReduceLit statt Goal, liefert ein
GoalReducer
nur bei reduce() einen Array aller node-Objekte, der bei ReduceLit auch noch
die Lit-Objekte enthält.
78
Beispiel — expr/oops
Zu jedem Grammatikbegriff muß eine Klasse mit dem Namen des Grammatikbegriffs
existieren, die Goal, Reduce, oder ReduceLit implementiert. Ist das nicht der Fall, wird
entweder eine mit -c angegebene defaultClass (zum Beispiel GoalDebugger) oder
GoalAdapter verwendet.
Für
lines
: [{ [ sum ] "\n" }];
sammelt man einfach die für sum gelieferten Unterbäume in einem Vector
:
{expr/oops/lines.java}
package expr.oops;
import java.util.Vector;
import oops.parser.Goal;
import oops.parser.GoalAdapter;
/** collect lines: [{ [ sum ] ’\n’ }] in a Vector.
*/
public class lines extends GoalAdapter {
/** stores a tree for each sum.
@serial lines Vector with one tree per sum
*/
protected Vector lines = new Vector();
/** presents result of reduction.
@param sender just received reduce().
@param node was created by sender.
*/
public void shift (Goal sender, Object node) {
lines.addElement(node);
}
/** concludes rule recognition.
@return Vector of sum trees.
*/
public Object reduce () {
return lines;
}
}
{}
79
Bei
sum
sum.add
sum.sub
: product [{ sum.add | sum.sub }];
: "+" product;
: "-" product;
erhält man für jedes product durch shift(Goal, Object) einen Unterbaum, den man aber je
nach Art des Operators mit Node.Add oder Node.Sub speichern muß. Das geht sehr leicht über
eine Rückfrage beim Absender :
{expr/oops/sum.java}
package expr.oops;
import oops.parser.Goal;
import oops.parser.GoalAdapter;
import expr.java.Node;
/** collect sum: product [{ ’+’ product | ’-’ product }] as a Node tree.
*/
public class sum extends GoalAdapter {
/** presents result of reduction.
This is saved, or combined with the current tree.
@param sender just received reduce(); must implement build.
@param node was created by sender; must be a Number.
*/
public void shift (Goal sender, Object node) {
if (result == null) result = node;
else result = ((build)sender).build((Number)result, (Number)node);
}
/** callback to build a tree.
*/
public interface build {
Object build (Number a, Number b);
}
/** builds a Node.Add tree.
*/
public static class add extends GoalAdapter implements build {
public Object build (Number a, Number b) {
return new Node.Add(a, b);
}
}
/** builds a Node.Sub tree.
*/
public static class sub extends GoalAdapter implements build {
public Object build (Number a, Number b) {
return new Node.Sub(a, b);
}
}
}
{}
80
Bei
product
product.mul
product.div
product.mod
:
:
:
:
term [{ product.mul | product.div | product.mod }];
"*" term;
"/" term;
"%" term;
arbeitet man natürlich mit Vererbung. Man muß nur die Absender implementieren
:
{expr/oops/product.java}
package expr.oops;
import oops.parser.GoalAdapter;
import expr.java.Node;
/** collect product: term [{ ’*’ term | ’/’ term | ’%’ term }] as a Node tree.
*/
public class product extends sum {
/** builds a Node.Mul tree.
*/
public static class mul extends GoalAdapter implements build {
public Object build (Number a, Number b) {
return new Node.Mul(a, b);
}
}
/** builds a Node.Div tree.
*/
public static class div extends GoalAdapter implements build {
public Object build (Number a, Number b) {
return new Node.Div(a, b);
}
}
/** builds a Node.Mod tree.
*/
public static class mod extends GoalAdapter implements build {
public Object build (Number a, Number b) {
return new Node.Mod(a, b);
}
}
}
{}
81
Bei
term
term.minus
: Number | "+" term | term.minus | "(" sum ")";
: "-" term;
sammeln GoalAdapter das von Number beziehungsweise einem Grammatikbegriff gelieferte
node-Objekt und ein modifizierter GoalAdapter konstruiert Node.Minus :
{expr/oops/term.java}
package expr.oops;
import oops.parser.GoalAdapter;
import expr.java.Node;
public class term extends GoalAdapter {
public static class minus extends GoalAdapter {
public Object reduce () {
return new Node.Minus((Number)result);
}
}
}
{}
82
Eine Grammatik mit oops prüfen
Nicht jede Grammatik eignet sich für Rekursiven Abstieg. Damit oops ein praktikables
Werkzeug ist, muß die Grammatik geprüft werden, wenn der Baum aus Parser und anderen
Klassen konstruiert wird.
oops akzeptiert eine in EBNF formulierte Grammatik, also Syntaxgraphen, und repräsentiert sie
als Baum. Ob der Baum als Parser erfolgreich sein kann, wird aus der Sicht der einzelnen
Knoten-Klassen betrachtet:
"x"
Number
und Token sollen Eingabesymbole erkennen. Das ist fest
umrissen.
Lit
seq
one
two
soll eine Folge von Dingen erkennen. Auch hier besteht
keine Wahlmöglichkeit.
Seq
one
alt
two
muß entscheiden, welcher Unterbaum (Teilgraph) aktiviert
werden soll.
Alt
opt
body
Opt
muß entscheiden, ob sein Unterbaum aktiviert werden soll.
many
body
Many
trifft diese Entscheidung mehrfach.
Some
trifft sie nach dem ersten Durchgang.
some
body
83
Betrachtet man die Situation für Alt einmal genauer:
one
alt
two
Offenbar müssen sich die Eingabesymbole vollständig unterscheiden, die am Anfang von one
und am Anfang von two vorkommen können.
opt
body
Bei Opt (und anderen) müssen sich aber auch die Eingabesymbole unterscheiden, die am
Anfang von body und im Anschluß an Opt vorkommen können.
Das gilt unter Umständen auch für Alt:
opt
body
alt
two
Wenn eine Alternative leer sein kann, müssen sich alle voneinander und von den nachfolgend
möglichen Eingabesymbolen unterscheiden.
Zur Prüfung einer Grammatik sollte jeder Knoten zwei Mengen von Eingabesymbolen
berechnen:
lookahead kann am Anfang vorkommen,
follow
kann danach vorkommen.
Anschließend muß jeder Knoten feststellen, ob er mit einem Eingabesymbol eindeutig
entscheiden kann, wie er sich verhalten soll.
84
public Set setLookahead (Parser parser)
:
lhs
rhs
Rule: Parser schickt setLookahead der Reihe nach an jede
Rule und diese, falls nötig, an ihren Unterbaum rhs; dessen
lookahead ist lookahead der Rule.
"x"
Lit:
lookahead enthält genau das Eingabesymbol selbst.
Number
Token:
wie Lit.
lhs
Id:
verweist auf Rule. Wenn diese noch mit setLookahead
beschäftigt ist, ist der Parser nicht anwendbar. Sonst kennt
oder berechnet sie ihre lookahead-Menge.
one
alt
two
Alt:
some
lookahead ist die Summe der Unterbäume.
body
Some:
lookahead kommt vom Unterbaum.
opt
body
lookahead kommt vom Unterbaum, aber keine Eingabe
ist ebenfalls möglich.
Opt:
many
body
Many:
wie Opt.
seq
one
two
lookahead kommt vom ersten Unterbaum; akzeptiert er
auch keine Eingabe, addiert man den zweiten Unterbaum etc.
Seq:
Um Id eine Chance zu geben, darf Seq nur die unbedingt nötigen Unterbäume befragen.
85
public Set setFollow (Parser parser, Set follow)
Diese Methode erhält den lookahead des Nachfolgers und liefert den eigenen lookahead.
Diesmal wird Seq vollständig traversiert und deshalb setLookahead intern vorausgeschickt.
lhs
:
rhs
Rule: Parser
schickt setFollow an die erste Rule und diese an
ihren Unterbaum rhs. Mangels anderer Kenntnisse ist follow
zuerst eine leere Menge.
"x"
Lit:
follow ist uninteressant.
Number
Token:
wie Lit.
one
alt
two
Alt:
some
follow wird notiert und jedem Unterbaum geschickt.
body
Some:
follow wird notiert und an den Unterbaum geschickt.
opt
body
Opt:
wie Some.
many
body
Many:
wie Opt.
seq
one
two
follow wird notiert und dem letzten Unterbaum two
geschickt. An den vorhergehenden, one, wird der lookahead
von two geschickt; akzeptiert two auch keine Eingabe, kommt
follow noch hinzu etc.
Seq:
lhs
verweist auf Rule. Vergrößert follow die dort bisher als
follow notierte Menge, muß das Verfahren iteriert werden.
Id:
Wenn die vom Parser an die erste Rule geschickte Nachricht setFollow terminiert, muß jede
Rule erreicht worden sein; es kann also keine Rule ohne follow geben.
86
public void checkLL1 (Parser parser)
:
lhs
rhs
Rule: Parser
schickt checkLL1 an die erste Rule und diese an
ihren Unterbaum rhs.
"x"
Lit:
nichts zu prüfen.
Number
Token:
wie Lit.
opt
body
alt
two
wenn lookahead (also eine Alternative) keine Eingabe
akzeptiert, muß lookahead von follow verschieden sein.
Außerdem müssen alle Unterbäume paarweise verschiedene
lookahead-Mengen besitzen.
Alt:
some
body
Some:
lookahead muß von follow verschieden sein.
opt
body
Opt:
wie Some.
many
body
Many:
wie Opt.
seq
one
two
Seq:
hier muß sich nur jeder Unterbaum selbst prüfen.
lhs
wenn lookahead (also die rhs der Rule) keine Eingabe
akzeptiert, muß lookahead von der follow-Menge dieses
Knotens verschieden sein..
Id:
87
Scanner
Wenn man den Compiler betreibt, muß man die Eingabesymbole mit den lookahead-Mengen
der Knoten vergleichen, um zu entscheiden, welche Unterbäume aktiviert werden sollen. Die
Knoten des von oops generierten Parsers enthalten folglich die lookahead-Mengen auch zur
Laufzeit.
und Token-Knoten enthalten Eingabesymbole in ihren lookahead-Mengen. Es muß also
möglich sein, diese Mengen mit jeweils einem Element leicht herzustellen.
Lit-
Diese Überlegungen führten zu folgendem Interface für Scanner für oops-Parser:
{oops/parser/Scanner.java}
package oops.parser;
import java.io.IOException;
import java.io.Reader;
/** describes what a scanner for an oops-generated parser must do.
*/
public interface Scanner {
/** initialize, read one symbol ahead.
@param parser is used to screen symbols.
*/
void scan (Reader in, Parser parser) throws IOException;
/** move on to next token.
@return false if atEnd() becomes true.
*/
boolean advance () throws IOException;
/** @return true if positioned beyond tokens.
*/
boolean atEnd ();
/** @return single-element lookahead set, null for unidentifiable token.
*/
Set tokenSet ();
/** @return node corresponding to token.
*/
Object node ();
}
{}
enthält Tabellen, die Strings und Token auf die nötigen Mengen abbilden, und stellt
Parser
sie mit entsprechenden Methoden zur Verfügung — deshalb ist scan() im Interface
vorgesehen, denn ein Konstruktor kann dort nicht vorgegeben werden.
88
Fazit
oops ist ein Experiment — ein objekt-orientiertes LL(1) System, dessen
Implementierungssprache ausgetauscht werden kann, ohne daß sich dies in der Grammatik
manifestiert. Wenn man die Konstruktion von Bäumen automatisiert und auf eine Bibliothek
aufsetzt, kann man (nahezu?) den gleichen Compiler in verschiedenen Sprachen
implementieren, wenn jeweils die Bibliothek vorhanden ist.
Da die Knoten zur Laufzeit die lookahead-Mengen enthalten und die follow-Mengen enthalten
könnten, sollte es möglich sein, für eine automatische, mächtige Fehlerbehandlung zu sorgen.
Durch die Aufteilung auf Klassen ist die LL(1)-Prüfung einigermaßen überschaubar. Der sonst
übliche Warshall-Algorithmus zur Berechnung der transitiven Hülle ist (weniger effizient?) in
der Iteration über Rule versteckt.
89
Ein Visitor-Generator für Objektbäume — expr/jag
Mit jag können Traversen für Objektbäume implementiert werden. jag liest eine Tabelle von
Mustern, die im Baum erkannt werden sollen, und in Java formulierten Aktionen, die dann
ausgeführt werden. Die Aktionen können unter anderem entscheiden, ob und wie
Unterbäume traversiert werden sollen.
jag beruht letztlich auf Wilcox’ Template-Codierer, mit dem etwa 1970 das PL/1-System bei
Cornell implementiert wurde. Der entscheidende Unterschied ist, daß nur Klassen zur
Formulierung von Mustern benötigt werden.
jag ist als Compiler mit jay und jlex implementiert, der Java-Code ausgibt, und beruht auf
einem Laufzeitsystem , das die Auswahl der Aktionen bewerkstelligt. Gegenüber meinen
früheren, als Interpreter arbeitenden Systemen hat dies den Vorteil, daß die Aktionen sehr
mächtig sind, ohne daß man die Transparenz von Auswahl und Traversier-Entscheidung
verliert. Der Nachteil ist ein wesentlich aufwendigerer Übersetzungsvorgang für Traversierer.
jag ist ebenfalls ein experimentelles Werkzeug. Es dient hier primär dazu, die Prinzipien
effizienter Code-Generierung für typische Rechnerarchitekturen möglichst kompakt
vorzustellen.
90
Node
Blätter des Objektbaums dürfen zu beliebigen Klassen gehören, Knoten müssen folgendes
Interface implementieren:
{jag/Node.java}
package jag;
public interface Node {
/** @return number of subtrees.
*/
int degree ();
/** provides access to subtrees.
@param n index of subtree, from 0.
@return root of n’th subtree.
@throws IndexOutOfBoundsException if n is out of range.
@throws NullPointerException if there are no subtrees.
*/
public Object sub (int n);
}
{}
Dies kann man in einer neuen Klasse Node
etwa folgendermaßen erreichen:
package expr.jag;
import java.io.Serializable;
public abstract class Node implements jag.Node, Serializable {
protected Serializable sub [];
public int degree () {
return sub == null ? 0 : sub.length;
}
public Object sub (int n) {
return sub[n];
}
public abstract static class Binary extends Node {
public Binary (Serializable left, Serializable right) {
sub = new Serializable[2]; sub[0] = left; sub[1] = right;
}
}
public interface Commutative extends Serializable {
}
public static class Add extends Binary implements Commutative {
public Add (Serializable left, Serializable right) {
super(left, right);
}
}
ist ein Beispiel, wie man Operator-Eigenschaften auf die Klassenhierarchie
abbilden kann.
Commutative
91
jlex und jay
Mit jlex kann man auch leicht Scanner konstruieren, die zu Parsern passen, die von jay
generiert wurden:
{expr/jag/Scanner.lex}
package expr.jag;
%%
%public
%class Scanner
%implements Expression.yyInput
%type boolean
%function advance
%eofval{
return false;
%eofval}
%{
/** current input.
*/
protected int token;
/** determines current input.
@return token value or character.
*/
public int token () {
return token;
}
/** value associated with current input.
*/
protected Object value;
/** produces value associated with current input.
@return value.
*/
public Object value () {
return value;
}
%}
comment = ("#".*)
space
= [\ \t\b\015]+
digit
= [0-9]
integer = {digit}+
real
= ({digit}+"."{digit}*|{digit}*"."{digit}+)
%%
{space}
{comment}
{integer}
{real}
.|\n
{ }
{ }
{ value = new Long(yytext()); token = Expression.Constant;
return true;
}
{ value = new Double(yytext()); token = Expression.Constant;
return true;
}
{ value = null; token = yytext().charAt(0);
return true;
}
{}
92
Expression
Abgesehen vom Betrieb der Traversierer wird Expression nahezu unverändert übernommen
(Vererbung ist nicht möglich, da Node ersetzt wird):
{expr/jag/Expression.jay}
%{
package expr.jag;
import
import
import
import
import
import
import
java.io.FileInputStream;
java.io.InputStreamReader;
java.io.ObjectInputStream;
java.io.ObjectOutputStream;
java.io.Serializable;
java.util.Vector;
jag.Jag;
/** recognizes, stores, and processes arithmetic expressions
using a parser generated with jay and jlex and visitors
generated with jag.
*/
public class Expression {
/** reads lines from standard input, parses, and visits them
with generators specified as arguments. Each generator operates
either on the original tree or on the object returned by the
preceding generator.
@param args filenames of visitors.
*/
public static void main (String args []) {
if (args == null || args.length == 0) {
System.err.println("no visitors specified"); System.exit(1);
}
try {
Scanner scanner = new Scanner(new InputStreamReader(System.in));
Expression parser = new Expression();
Vector lines = (Vector)parser.yyparse(scanner);
{}
93
{expr/jag/Expression.jay rules}
} catch (Exception e) { System.err.println(e); }
}
%}
%token
%type
%type
%%
<Serializable> Constant
<Vector>
lines
<Serializable> sum, product, term
lines
: /* null */
| lines sum ’\n’
| lines ’\n’
{ $$ = new Vector(); }
{ $1.addElement($2); }
// $$ = $1
sum
: product
| sum ’+’ product
| sum ’-’ product
// $$ = $1
{ $$ = new Node.Add($1, $3); }
{ $$ = new Node.Sub($1, $3); }
product :
|
|
|
term
product ’*’ term
product ’/’ term
product ’%’ term
// $$ = $1
{ $$ = new Node.Mul($1, $3); }
{ $$ = new Node.Div($1, $3); }
{ $$ = new Node.Mod($1, $3); }
term
’+’ term
’-’ term
’(’ sum ’)’
Constant
{ $$ = $2; }
{ $$ = new Node.Minus($2); }
{ $$ = $2; }
// $$ = $1
:
|
|
|
%%
}
{}
94
Traverse
In der (eindeutigen) Postfix-Notation folgen die Operatoren ihren Operanden. Man gewinnt sie
aus einem Baum durch eine Postorder-Traverse, bei der die Wurzeln nach ihren Unterbäumen
bearbeitet werden :
{expr/jag/Postfix.jag}
package expr.jag;
import
import
import
import
java.io.IOException;
java.io.ObjectOutputStream;
java.io.Serializable;
jag.Jag;
/** generates methods to traverse a Node tree to generate postfix.
*/
public class Postfix {
public static void main (String args []) {
final Jag jag = new Jag();
%%
Double:
Long:
{ ‘" "+$0.floatValue()‘; };
{ ‘" "+$0.longValue()‘; };
Node.Add:
Node.Mul:
Node.Sub:
Node.Div:
Node.Mod:
Node.Minus:
{
{
{
{
{
{
Node: Serializable Serializable
| Serializable
{ $1; $2; $0; }
{ $1; $0; };
‘"
‘"
‘"
‘"
‘"
‘"
add"‘; };
mul"‘; };
sub"‘; };
div"‘; };
mod"‘; };
minus"‘; };
%%
try {
ObjectOutputStream out = new ObjectOutputStream(System.out);
out.writeObject(jag);
out.close();
} catch (java.io.IOException e) { System.err.println(e); System.exit(1); }
}
}
{}
Dies erzeugt etwa folgende Ausgabe:
$ java expr.jag.Expression Postfix.gen
2 + 3
2 3 add
95
Format
Die Eingabe für jag hat drei Teile, die durch %% getrennt sind. Im ersten Teil muß mit
Java-Code eine final oder Instanzen-Variable Jag jag vereinbart werden. Der zweite Teil
enthält die Tabelle mit Einträgen aus Mustern und Aktionen, die in jag aufgebaut wird. Der
dritte Teil enthält abschließenden Java-Code. Normalerweise wird man dort jag serialisieren.
Eintrag
besteht aus einer Klasse und einer Folge von Mustern und Aktionen. Die Klasse
eines Knotens oder eine Oberklasse oder ein Interface, jeweils mit wachsendem
Abstand von der Klasse, werden unter den Einträgen gesucht.
Muster
ist eine Liste von null oder mehr Klassen. Die Klassen der Unterbäume eines
Knotens müssen an die Klassen der Liste eines Musters zugewiesen werden
können, dann wird die zugehörige Aktion gewählt. Da die Muster sequentiell
durchsucht werden, muß man Muster mit spezielleren Klassen am Anfang eines
Eintrags aufführen.
an Stelle einer Klasse bedeutet, daß an dieser Stelle kein Unterbaum vorhanden
ist. Darauf darf dann auch nicht verwiesen werden.
Folgen dem Muster drei Punkte, werden auch Knoten erkannt, die mehr als die
angegebenen Unterbäume besitzen.
null
...
Aktion
ist Java-Code mit spezieller Syntax zum Verweis auf oder zur Traverse von
Knoten und zur Ausgabe:
‘...‘
wird ersetzt durch jag.getOut().print(...), vereinfacht also Ausgaben.
$n
$0
verweist auf die Wurzel eines Unterbaums, mit Typumwandlung zur Klasse.
verweist auf den Knoten selbst, mit Typumwandlung zur Klasse.
$n;
$0;
{$n} {$0}
traversiert einen Unterbaum.
führt eine Aktion zur Klasse des Knotens aus, deren Muster eine leere Liste ist.
funktionieren analog, werden aber nicht implizit mit ; terminiert.
Fehlt die Aktion komplett, wird nach einem anderen passenden Eintrag für den Knoten
gesucht. Eine Aktion wird folgendermaßen übersetzt:
public final Object action (Object object, Object parm) {
Object result = null;
jag.gen(((jag.Node)object).sub(0), parm);
jag.gen(((jag.Node)object).sub(1), parm);
jag.gen0(object, parm);
return result;
}
ist der Knoten, parm wird beim Start der Traverse angegeben, result kann in der
Aktion gesetzt und beim Aufrufer in Form von {$n} verwendet werden. Die Aktion sollte keine
Namen einführen, die mit einem Unterstrich beginnen.
object
96
Implementierung
Traversierer beruhen im Wesentlichen auf zwei Klassen: Jag implementiert die Suche nach
den Einträgen, die zu Knoten passen, und Jag.Rule implementiert die Prüfung eines Musters
und die zugehörige Aktion.
ist ein Hashtable, der Class-Objekte auf Rule-Arrays abbildet. Extern sichtbar ist
folgendes:
Jag
public class Jag extends Hashtable {
public Object gen (Object object, Object parm)
public static class NoRuleException extends RuntimeException
public Object gen0 (Object object, Object parm)
public void setOut (PrintStream out)
public PrintStream getOut ()
public static class Rule implements Serializable {
public Rule (Class sub [])
public Object action (Object object, Object parm)
public boolean matches (int degree, Node node)
}
}
Ein Aufruf von gen() startet die Traverse oder führt sie rekursiv weiter und liefert das Resultat
der äußersten action(). Eine Jag.NoRuleException zeigt an, daß keine passende Rule
gefunden wurde. gen() implementiert die Suche nach passenden Einträgen.
dient zur Suche und Ausführung einer Rule mit leerem Muster. setOut() und getOut()
kontrollieren die Ausgabe der mit ‘...‘ formulierten Aktionen.
gen0()
enthält einen Array mit Klassen für die Unterbäume, der im Konstruktor unveränderlich
gesetzt wird. Von Rule wird für jede spezifizierte Aktion eine lokale Unterklasse erzeugt, in der
action() mit der Aktion überschrieben wird.
Rule
matches() berücksichtigt primär degree als gesuchte Array-Länge; nur wenn dies positiv ist,
wird die Node inspiziert — damit kann nach Regeln ohne Muster gesucht werden, selbst wenn
die Node Unterbäume besitzt. Falls Rule nicht abgeleitet wurde und matches() Erfolg hat,
liefert matches() eine NoRuleException — damit wird in gen() und gen0() erzwungen, daß die
Suche mit Einträgen für Oberklassen fortgesetzt wird.
jag ist quasi ein Präprozessor, der die Konstruktion eines Jag-Objekts, also eines
Traversierers, vereinfacht. Abgesehen von einfachen semantischen Prüfungen (nur ein Eintrag
pro Klasse? keine Aktionen bezüglich nicht-existenter Unterbäume?) wird im Wesentlichen der
ursprünglich von Hand entworfene Java-Code zur Konstruktion eines Jag-Objekts
umgesetzt. Die jay-Implementierung des Parsers enthält einige Kontext-Tricks mit Variablen
wie $0 und die jlex-Implementierung des Scanners stützt sich sehr stark auf Zustände, um
Regeln, Aktionen und Ausgabe-Aktionen verschieden zu analysieren.
97
Betrieb
Im einem Programm können mehrere Traversierer, auch simultan, betrieben werden.
Beispielsweise wird Expression mit serialisierten Traversierern aufgerufen und wendet sie
nacheinander auf jeden vom Parser in Vector lines eingetragenen Baum an:
{expr/jag/Expression.jay jag}
Jag jag [] = new Jag[args.length];
for (int a = 0; a < args.length; ++ a) {
jag[a] = (Jag) new ObjectInputStream(new FileInputStream(args[a]))
.readObject();
// load generator
jag[a].setOut(System.err);
// redirect output
}
Vector output = new Vector();
for (int l = 0; l < lines.size(); ++ l) {
Object tree = lines.elementAt(l);
for (int a = 0; a < args.length; ++ a) {
Object result = jag[a].gen(tree, null);
// run generator
if (result == null) System.err.println();
else tree = result;
}
if (tree != null) output.addElement(tree);
}
if (output.size() != 0) {
ObjectOutputStream out = new ObjectOutputStream(System.out);
out.writeObject(output);
out.close();
}
{}
Liefert ein Traversierer ein Resultat, wird es hier als Baum für den nächsten Traversierer
verwendet. Ist zum Schluß aller Traversen das Resultat nicht null, wird es in Vector output
eingetragen. Ist er nicht leer, wird dieser Vector zum Schluß serialisiert und kann dann
möglicherweise mit expr.java.Go weiterverwendet werden:
$ java expr.jag.Expression Postfix.gen Value.gen | java expr.java.Go
2 + 3
2 3 add
byte
short
int
long
float
double
5
5
5
5
5.0
5.0
98
Beispiel: Bewertung
Um einen Baum zu bewerten, erzeugt man in jeder Aktion ein Double-Objekt:
{expr/jag/Value.jag}
package expr.jag;
import java.io.IOException;
import java.io.ObjectOutputStream;
import java.io.Serializable;
/** evaluates a Node tree with double arithmetic.
*/
public class Value {
public static void main (String args []) {
final jag.Jag jag = new jag.Jag();
%%
Number:
{ result = ($0); };
Node.Add: Serializable Serializable {
result = new Double(((Number){$1}).doubleValue()
+ ((Number){$2}).doubleValue());
};
Node.Sub: Serializable Serializable {
result = new Double(((Number){$1}).doubleValue()
- ((Number){$2}).doubleValue());
};
Node.Mul: Serializable Serializable {
result = new Double(((Number){$1}).doubleValue()
* ((Number){$2}).doubleValue());
};
Node.Div: Serializable Serializable {
result = new Double(((Number){$1}).doubleValue()
/ ((Number){$2}).doubleValue());
};
Node.Mod: Serializable Serializable {
result = new Double(((Number){$1}).doubleValue()
% ((Number){$2}).doubleValue());
};
Node.Minus: Serializable
{
result = new Double(-((Number){$1}).doubleValue());
};
%%
try {
ObjectOutputStream out = new ObjectOutputStream(System.out);
out.writeObject(jag);
out.close();
} catch (IOException e) { System.err.println(e); System.exit(1); }
}
}
{}
99
Beispiel: Generieren eines expr.java.Node Baums
Statt Double kann man natürlich auch die bisher verwendeten Knoten erzeugen:
{expr/jag/Tree.jag}
package expr.jag;
import java.io.IOException;
import java.io.ObjectOutputStream;
import java.io.Serializable;
/** creates an expr.java.Node tree from a Node tree.
*/
public class Tree {
public static void main (String args []) {
final jag.Jag jag = new jag.Jag();
%%
Number:
{ result = ($0); };
Node.Add: Serializable Serializable {
result = new expr.java.Node.Add((Number){$1}, (Number){$2});
};
Node.Sub: Serializable Serializable {
result = new expr.java.Node.Sub((Number){$1}, (Number){$2});
};
Node.Mul: Serializable Serializable {
result = new expr.java.Node.Mul((Number){$1}, (Number){$2});
};
Node.Div: Serializable Serializable {
result = new expr.java.Node.Div((Number){$1}, (Number){$2});
};
Node.Mod: Serializable Serializable {
result = new expr.java.Node.Mod((Number){$1}, (Number){$2});
};
Node.Minus: Serializable
{
result = new expr.java.Node.Minus((Number){$1});
};
%%
try {
ObjectOutputStream out = new ObjectOutputStream(System.out);
out.writeObject(jag);
out.close();
} catch (IOException e) { System.err.println(e); System.exit(1); }
}
}
{}
100
Code-Generierung
Im folgenden wird mit jag Code für eine Reihe von idealisierten Maschinen generiert. Als
Beispiel dient immer der arithmetische Ausdruck - (1 % ( 2 + 3 * 4 - 5 / 6 )), der dem
folgenden Baum entspricht:
-
%
1
-
+
2
/
*
3
5
6
4
Prinzipiell könnte man Postfix erzeugen und auf jeder Maschine einfach einen Stack
simulieren, aber es gibt Lösungen, die der Architektur gerechter werden.
Zur Analyse der Beispiele sollte man beachten, daß möglichst wenig Speicherplatz verbraucht
werden sollte. Dazu ist es sehr wichtig, daß man je nach Operator die Reihenfolge variiert, in
der die Unterbäume besucht werden — ein wesentlicher Grund für die Konstruktion von jag.
101
Beispiel: Code für eine 0-Adreß-Maschine
Eine 0-Adreß- oder Stack-Maschine führt ihre Berechnungen mit Werten von einem (prinzipiell
unendlichen) Stack durch — Arithmetikbefehle wie add haben folglich keine expliziten
Argumente.
push
rechts
pop
Arithmetik
links
pop
Stack
Zusätzlich muß es load- und store-Befehle geben, die Werte zwischen einem Speicher und
dem Stack transferieren.
Eine naive Postfix-Traverse liefert zwar 0-Adreß-Code, aber sie benötigt unnötig viel Platz auf
dem Stack. Für den Ausdruck - (1 % ( 2 + 3 * 4 - 5 / 6 )) kann zum Beispiel folgender
Code erzeugt werden:
load
load
load
mul
load
add
load
load
div
sub
mod
minus
1.0
4.0
3.0
2.0
5.0
6.0
102
Der Trick besteht darin, bestimmte Situationen bei kommutativen Operatoren auszunützen:
{jag/arith/G0.jag}
package arith;
import
import
import
import
java.io.IOException;
java.io.ObjectOutputStream;
java.io.Serializable;
expr.jag.Node;
/** generates methods to traverse a Node tree to generate 0-address code.
*/
public class G0 {
public static void main (String args []) {
final jag.Jag jag = new jag.Jag();
%%
Double:
{ ‘"\tload\t"+$0.floatValue()+"\n"‘; };
Node.Add:
Node.Mul:
Node.Sub:
Node.Div:
Node.Mod:
Node.Minus:
{
{
{
{
{
{
Node.Commutative: Double Serializable
{ $2; $1; $0; };
Node: Serializable Serializable
| Serializable
{ $1; $2; $0; }
{ $1; $0; };
‘"\tadd\n"‘; };
‘"\tmul\n"‘; };
‘"\tsub\n"‘; };
‘"\tdiv\n"‘; };
‘"\tmod\n"‘; };
‘"\tminus\n"‘; };
%%
try {
ObjectOutputStream out = new ObjectOutputStream(System.out);
out.writeObject(jag);
out.close();
} catch (IOException e) { System.err.println(e); System.exit(1); }
}
}
{}
103
Beispiel: Code für eine 1-Adreß-Maschine
Eine 1-Adreß-Maschine gleicht einem Taschenrechner mit Display. Arithmetikbefehle haben
ein Argument und verknüpfen es mit dem Display:
load
store
Arithmetik,
Resultat
Speicher
add
Zusätzlich zu den Arithmetikbefehlen muß es load und store geben, um den Display laden
und entladen zu können.
Für den Ausdruck - (1 % ( 2 + 3 * 4 - 5 / 6 )) kann zum Beispiel folgender Code erzeugt
werden:
load
div
store
load
mul
add
sub
store
load
mod
store
load
sub
5.0
6.0
temp1
4.0
3.0
2.0
temp1
temp1
1.0
temp1
temp1
0.0
temp1
Der Code wird deutlich verbessert, wenn es einen 0-Adreß-Befehl minus gibt, der das
Vorzeichen des Displays umkehrt.
104
{jag/arith/G1.jag}
package arith;
import
import
import
import
java.io.IOException;
java.io.ObjectOutputStream;
java.io.Serializable;
expr.jag.Node;
/** generates methods to traverse a Node tree to generate 1-address code.
*/
public class G1 {
public static void main (String args []) {
final jag.Jag jag = new jag.Jag();
final jag.Temp temp = new jag.Temp();
%%
Double:
{ ‘"
load
"+$0.floatValue()+"\n"‘; };
Node.Add:
Node.Mul:
Node.Sub:
Node.Div:
Node.Mod:
{
{
{
{
{
add
mul
sub
div
mod
"‘;
"‘;
"‘;
"‘;
"‘;
Node.Minus: Double
{ ‘"
}
{
‘"
‘"
‘"
};
load
-("+$1.floatValue()+")\n"‘;
$1;
store
load
sub
temp"+temp.get()+"\n"‘;
0.0\n"‘;
temp"+temp.free()+"\n"‘;
{
$2;
$0;
‘$1.floatValue()+"\n"‘;
$1;
$0;
‘$2.floatValue()+"\n"‘;
| Serializable
Node.Commutative:
Double Serializable
‘"
‘"
‘"
‘"
‘"
};
};
};
};
};
};
Node: Serializable Double
| Serializable Serializable
{
}
{
‘"
$2;
store
$1;
$0;
temp"+temp.get()+"\n"‘;
‘"temp"+temp.free()+"\n"‘;
};
%%
try {
ObjectOutputStream out = new ObjectOutputStream(System.out);
out.writeObject(jag);
out.close();
} catch (IOException e) { System.err.println(e); System.exit(1); }
}
}
{}
105
Temp
Zwischenergebnisse müssen gelegentlich aus dem Display abgespeichert werden. Die
Speicherzellen können als Stack mit einer Klasse Temp verwaltet werden, die als Hilfsmittel
zum Paket jag gehört:
{jag/Temp.java}
package jag;
import java.io.Serializable;
/** implements indices for a stack of runtime words.
*/
public class Temp implements Serializable {
/** current stack level.
*/
protected transient int n;
// starts life at zero
/** creates a new level.
@return current level.
*/
public int get () { return ++ n; }
/** references current level.
@return current level.
*/
public int ref () { return n; }
/** discards current level.
@return discarded level.
*/
public int free () { return n --; }
}
{}
106
Beispiel: Code für eine 2-Adreß-Maschine
2-Adreß-Maschinen sind die theoretische Version der heute gebräuchlichsten Architektur. Sie
sind theoretisch wichtig, weil ihr Code (Tripel) leicht optimiert und auf andere Architekturen
abgebildet werden kann. Ein Arithmetikbefehl hat zwei Argumente, verknüpft sie, und
überschreibt dann eines davon mit dem Resultat:
links
sum
Speicher
Arithmetik
rechts
Zusätzlich zu den Arithmetikbefehlen sollte es noch einen Befehl move zur Übertragung von
Werten geben.
Für den Ausdruck - (1 % ( 2 + 3 * 4 - 5 / 6 )) kann zum Beispiel folgender Code erzeugt
werden:
move
move
move
div
move
mul
add
sub
mod
sub
temp1,0.0
temp2,1.0
temp3,5.0
temp3,6.0
temp4,3.0
temp4,4.0
temp4,2.0
temp3,temp4
temp2,temp3
temp1,temp2
Der Code wird deutlich verbessert, wenn es einen 1-Adreß-Befehl minus gibt, der das
Vorzeichen einer Speicherzelle umkehrt.
107
Um explizite Speicherverwaltung zu vermeiden, kann man das Resultat immer in der
Speicherzelle erzeugen, die Temp.get() als nächste anlegen würde:
Node.Minus: Double
{ ‘"
move
| Serializable
}
{ ‘"
move
‘"
temp"+temp.get()+","
+"-("‘; $1; ‘")\n"‘;
temp.free();
temp"+temp.get()+","
+"0.0\n"‘;
$1;
sub
temp"+temp.ref()+","
+"temp"+temp.get()+"\n"‘;
temp.free(); temp.free();
};
Node.Commutative: Double Double // revert to superclass of node
| Double Serializable
{
$2;
$0;
‘"temp"+temp.get()+","‘;
$1; ‘"\n"‘;
temp.free();
};
Node: Double Double
{ ‘"
move
$0;
| Serializable Double
}
{
temp"+temp.get()+","‘;
$1; ‘"\n"‘;
‘"temp"+temp.free()+","‘;
$2; ‘"\n"‘;
$1;
$0;
‘"temp"+temp.get()+","‘;
$2; ‘"\n"‘;
temp.free();
| Double Serializable
}
{ ‘"
move
temp"+temp.get()+","‘;
$1; ‘"\n"‘;
$2;
$0;
‘"temp"+temp.ref()+","
+"temp"+temp.get()+"\n"‘;
temp.free(); temp.free();
| Serializable Serializable
}
{
$2;
temp.get();
$1;
$0;
‘"temp"+temp.ref()+","
+"temp"+temp.get()+"\n"‘;
temp.free(); temp.free();
};
demonstriert, wie man bei der Ersetzung von Spezialfällen auf die
sequentielle Suche in den Regeln Rücksicht nehmen muß. Die Code-Generierung für die
Register-Maschine zeigt, wie eine intelligentere Speicherverwaltung mehr Platz spart.
Node.Commutative
108
Beispiel: Code für eine 3-Adreß-Maschine
3-Adreß-Maschinen sind theoretisch wichtig, weil ihr Code (Quadrupel) leicht optimiert und
dann auf andere Architekturen abgebildet werden kann. Ein Arithmetikbefehl hat drei
Argumente, verknüpft zwei und überschreibt das dritte mit dem Resultat.
sum
Speicher
links
Arithmetik
rechts
Zusätzlich zu den Arithmetikbefehlen sollte es noch einen Befehl move zur Übertragung von
Werten geben.
Für den Ausdruck - (1 % ( 2 + 3 * 4 - 5 / 6 )) kann zum Beispiel folgender Code erzeugt
werden:
mul
add
div
sub
mod
sub
temp1,3.0,4.0
temp1,2.0,temp1
temp2,5.0,6.0
temp1,temp1,temp2
temp1,1.0,temp1
temp1,0.0,temp1
109
Um explizite Speicherverwaltung zu vermeiden, kann man das Resultat immer in der
Speicherzelle erzeugen, die Temp.get() als nächste anlegen würde:
Node.Minus: Double
| Serializable
{ ‘"
}
{
‘"
move
temp"+temp.get()+","
+"-("‘; $1; ‘")\n"‘;
temp.free();
$1;
sub
temp"+temp.get()+","
+"0.0,"
+"temp"+temp.free()+"\n"‘;
};
Node: Double Double
| Serializable Double
{
}
{
$0;
‘"temp"+temp.get()+","‘;
$1; ‘","‘;
$2; ‘"\n"‘;
temp.free();
$1;
$0;
‘"temp"+temp.get()+","
+"temp"+temp.ref()+","‘;
$2; ‘"\n"‘;
temp.free();
| Double Serializable
}
{
$2;
$0;
‘"temp"+temp.get()+","‘;
$1; ‘","
+"temp"+temp.ref()+"\n"‘;
temp.free();
| Serializable Serializable
}
{
$1;
temp.get();
$2;
$0;
‘"temp"+temp.ref()+","
+"temp"+temp.ref()+","
+"temp"+temp.get()+"\n"‘;
temp.free(); temp.free();
};
Offensichtlich kann man leichter 3-Adreß- als 2-Adreß-Code generieren. Trotzdem hat sich
diese Architektur nicht durchgesetzt, denn die 3-Adreß-Programme benötigen mehr
Speicherplatz als 2-Adreß-Programme, weil die Befehle in der Regel Platz verschwenden.
110
Beispiel: Mixed-Mode Code für eine Register-Maschine
Die Register-Maschine ist ein Spezialfall einer 2-Adreß-Maschine. Es gibt relativ wenige,
schnelle Register mit einfachen Adressen, die als Quelle und Ziel der Arithmetikbefehle
dienen. Moderne RISC-Architekturen haben sehr viele Register; trotzdem ist
Register-Verwaltung nicht trivial, denn Register werden auch zur Adressierung des Speichers
benötigt.
links
sum
Register
Arithmetik
rechts
Speicher
Für den Ausdruck - (1 % ( 2 + 3 * 4 - 5 / 6 )) kann zum Beispiel folgender Code erzeugt
werden:
load
mul
add
load
div
subr
load
modr
load
subr
r0,
r0,
r0,
r1,
r1,
r0,
r1,
r1,
r0,
r0,
3
4
2
5
6
r1
1
r0
0
r1
Der Code ist deutlich kürzer als 0-Adreß-Code, da die Register nicht als Stack verwaltet
werden.
111
Der Parser kann Long und Double erzeugen. Die Register-Maschine besitzt verschiedene
Register und Befehle für Ganzzahl- und Gleitkomma-Arithmetik, und im Stil von C muß zur
Arithmetik Double verwendet werden, wenn wenigstens ein Operand diesen Typ besitzt.
Hilfsklassen R.Long und R.Float die beide von R abstammen, verwalten die Register, und ihre
Objekte repräsentieren den Resultattyp eines codierten Befehls:
Node: Long Long
| Number Number
| Long Serializable
| Number Serializable
| Serializable Long
| Serializable Number
| Serializable
R left =
if (left
result
else
result
};
{expr/jag/Reg.jag rules}
{ result = new R.Long((String){$0},
new R.Long($1), $2);
}
{ result = new R.Float((String){$0},
new R.Float($1), $2);
}
{ R right = (R)$2;
result = right instanceof R.Long ?
(R)new R.Long((String){$0},
new R.Long($1),right) :
(R)new R.Float((String){$0},
new R.Float($1),right);
}
{ R right = (R)$2;
result = new R.Float((String){$0},
new R.Float($1), right);
}
{ R left = (R)$1;
result = left instanceof R.Long ?
(R)new R.Long((String){$0}, left, $2) :
(R)new R.Float((String){$0}, left, $2);
}
{ R left = (R)$1;
result = new R.Float((String){$0}, left, $2);
}
Serializable {
(R){$1}, right = (R)$2;
instanceof R.Long && right instanceof R.Long)
= new R.Long((String){$0}, left, right);
= new R.Float((String){$0}, left, right);
{}
112
Der Code kann noch durch folgende Spezialfälle verbessert werden:
Node.Commutative: Long Long
| Number Number
| Long Serializable
| Number Serializable
Die Wurzeln enthalten wie üblich die
Node.Add:
Node.Mul:
Node.Sub:
Node.Div:
Node.Mod:
Node.Minus: Long
| Number
| Serializable
{expr/jag/Reg.jag rules}
// revert to superclass of node
// revert to superclass of node
{ R right = (R)$2;
result = right instanceof R.Long ?
(R)new R.Long((String){$0}, right,$1) :
(R)new R.Float((String){$0}, right,$1);
}
{ R right = (R)$2;
result = new R.Float((String){$0}, right,$1);
};
{}
Maschinenbefehle. Minus wird auf sub abgebildet:
{expr/jag/Reg.jag rules}
{ result = "add"; };
{ result = "mul"; };
{ result = "sub"; };
{ result = "div"; };
{ result = "mod"; };
{ result = new R.Long("sub",
new R.Long(new Long(0)), $1);
}
{ result = new R.Float("sub",
new R.Float(new Float(0)), $1);
}
{ R right = (R)$1;
if (right instanceof R.Long)
result = new R.Long("sub",
new R.Long(new Long(0)),right);
else
result = new R.Float("sub",
new R.Float(new Long(0)),right);
};
{}
113
Die Registerverwaltung muß zwischen zwei Ausdrücken reinitialisiert werden. Dazu kann ein
kleiner Trick dienen: Jag wird so abgeleitet, daß der Generator seinen obersten Aufruf daran
erkennt, daß kein Argument übergeben wird:
{expr/jag/Reg.jag}
package expr.jag;
import java.io.IOException;
import java.io.ObjectOutputStream;
import java.io.Serializable;
/** generates register machine code for a mixed-mode Node tree.
*/
public class Reg {
public static void main (String args []) {
final jag.Jag jag = new jag.Jag() {
public Object gen (Object object, Object parm) {
Object result = super.gen(object, this);
if (parm == null) { R.reset(); result = null; }
return result;
}
};
%%
{expr/jag/Reg.jag tail}
%%
try {
ObjectOutputStream out = new ObjectOutputStream(System.out);
out.writeObject(jag);
out.close();
} catch (IOException e) { System.err.println(e); System.exit(1); }
}
}
{}
Die Code-Generierung ist nicht eingerichtet auf triviale Ausdrücke, die nur aus einer
Konstanten bestehen.
114
Registerverwaltung — R
ist abstrakt und repräsentiert ein Register als Index in einen Array, in dem die Belegung
notiert wird. Dieser Array müßte verfeinert werden, wenn man bei Überbelegung Register in
den Speicher verdrängt.
R
Konkrete Unterklassen von R repräsentieren Long- und Double-Arithmetik. Die Konstruktoren
erzeugen Maschinenbefehle und notieren die Resultatregister.
Die nötigen Konstruktoren ergeben sich durch Besichtigung der jag-Regeln. R.Float hat einen
zusätzlichen Konstruktor zur Umwandlung von Long in Double.
{expr/jag/R.java}
package expr.jag;
/** manages registers and output for a register machine.
Nested classes use package access to register descriptions.
*/
public abstract class R {
/** makes all registers available.
*/
public static void reset () {
for (int r = 0; r < Long.reg.length; ++ r) Long.reg[r] = false;
for (int r = 0; r < Float.reg.length; ++ r) Float.reg[r] = false;
}
/** allocates a register.
@throws RuntimeException if there is no register available.
*/
protected static int get (boolean reg []) {
for (int r = 0; r < reg.length; ++ r)
if (! reg[r]) {
reg[r] = true;
return r;
}
throw new RuntimeException("no more registers");
}
/** register containing this result.
*/
protected int rX;
115
/** describes a result in a general register.
*/
public static class Long extends R {
/** number of general registers for integer arithmetic.
*/
public static final int regs = 8;
/** true if general register is in use.
*/
protected static boolean reg [] = new boolean[regs];
/** loads a number into a new general register.
@throws RuntimeException if there is no register available.
*/
public Long (java.lang.Long number) {
rX = R.get(reg);
System.err.println("\tload\tr"+rX+", "+number.longValue());
}
/** combines a general register and a number.
@param left general register.
*/
public Long (String opcode, R left, java.lang.Long right) {
rX = ((Long)left).rX;
// ensure left is Long
System.err.println("\t"+opcode+"\tr"+rX+", "+right.longValue());
}
/** combines two general registers.
@param left general register, used as result.
@param right general register, freed.
*/
public Long (String opcode, R left, R right) {
rX = ((Long)left).rX;
// ensure left is Long
System.err.println("\t"+opcode+"r\tr"+rX+", r"+right.rX);
reg[right.rX] = false;
((Long)right).rX = -1;
// ensure right is Long and gets trashed
}
}
116
/** describes a result in
*/
public static class Float
/** number of registers
*/
public static final int
a floating point register.
extends R {
for floating point arithmetic.
regs = 8;
/** true if floating point register is in use.
*/
protected static boolean reg [] = new boolean[regs];
/** loads a number into a new floating point register.
@throws RuntimeException if there is no register available.
*/
public Float (Number number) {
rX = R.get(reg);
System.err.println("\tloadf\tfr"+rX+", "+number.floatValue());
}
/** transfers a number into a new floating point register
and frees the general register.
@param r general register.
@throws RuntimeException if there is no register available.
*/
public Float (R r) {
rX = R.get(reg);
System.err.println("\tloadfr\tfr"+rX+", r"+r.rX);
Long.reg[r.rX] = false;
((Long)r).rX = -1;
// ensure r is Long and gets trashed
}
/** combines a register and a number.
@param left register, converted to floating point if necessary.
*/
public Float (String opcode, R left, Number right) {
if (! (left instanceof Float)) left = new Float(left);
rX = left.rX;
System.err.println("\t"+opcode+"f\tfr"+rX+", "+right.floatValue());
}
/** combines two registers.
@param left register, converted to floating point, used as result.
@param right register, converted as needed, freed.
*/
public Float (String opcode, R left, R right) {
if (! (left instanceof Float)) left = new Float(left);
rX = left.rX;
if (! (right instanceof Float)) right = new Float(right);
System.err.println("\t"+opcode+"fr\tfr"+rX+", fr"+right.rX);
reg[right.rX] = false;
right.rX = -1;
// ensure right gets trashed
}
}
}
{}
117
Test
Die Code-Generierung für die Register-Maschine hängt nur von einer Baum-Ebene ab. Es
bietet sich an, alle vier binären und zwei monadischen Muster zu testen. Dabei muß man
allerdings auch jeweils zwei mögliche Typen sowie die Vereinfachung durch Kommutativität
berücksichtigen — aus 6 werden dadurch 28 Testfälle.
Widen ist ein Traversierer, der arithmetische Ausdrücke
Double-Werte jeweils auch Long-Werte einsetzt:
dadurch vervielfältigt, daß er für
{expr/jag/Widen.jag}
package expr.jag;
import java.io.IOException;
import java.io.ObjectOutputStream;
import java.io.Serializable;
/** widens a mixed-mode Node tree by exploding Double(n) into n and n.
*/
public class Widen {
public static void main (String args []) {
final jag.Jag jag = new jag.Jag() {
public Object gen (Object object, Object parm) {
Object result = super.gen(object, this);
if (parm == null && result != null) {
String[] r = (String[])result; result = null;
for (int i = 0; i < r.length; ++ i)
System.err.println(r[i]);
}
return result;
}
};
%%
Node.Add:
Node.Mul:
Node.Sub:
Node.Div:
Node.Mod:
Node.Minus:
{
{
{
{
{
{
result
result
result
result
result
result
=
=
=
=
=
=
" + "; };
" * "; };
" - "; };
" / "; };
" % "; };
"- "; };
Long: {
result = new String[] { ""+$0.longValue() };
};
Double: {
result = new String[] { ""+$0.longValue(), ""+$0.longValue()+"." };
};
118
Node: Serializable Serializable {
String[] left = (String[]){$1}, right = (String[])$2;
String[] rs = new String[left.length * right.length];
for (int l = 0; l < left.length; ++ l)
for (int r = 0; r < right.length; ++ r)
rs[l + r*left.length] = "("+left[l]+{$0}+right[r]+")";
result = rs;
}
| Serializable {
String[] sub = (String[])$1;
String[] rs = new String[sub.length];
for (int s = 0; s < sub.length; ++ s)
rs[s] = "("+{$0}+sub[s]+")";
result = rs;
};
%%
try {
ObjectOutputStream out = new ObjectOutputStream(System.out);
out.writeObject(jag);
out.close();
} catch (IOException e) { System.err.println(e); System.exit(1); }
}
}
{}
Alle Testfälle bearbeitet man etwa so:
$ { java expr.jag.Expression Widen.gen >/dev/null; } 2>&1 |
java expr.jag.Expression Reg.gen > /dev/null
1.+2.
3.-4.
5.+(6+7.)
8.-(9+10.)
(11+12.)-13.
(14+15.)-(16+17.)
-18.
-(19+20.)
119
Fazit
Es ist genügend oft demonstriert worden, daß sich das Prinzip von jag — Baumtraversen
basierend auf Mustererkennung mit programmierbarer Traversierrichtung — sehr gut zur
effizienten Code-Generierung eignet. Man kommt schnell zu einer prinzipiellen Lösung und
kann diese durch die Erkennung von Spezialfällen laufend verbessern.
Die vorliegenden Beispiele sollten dokumentieren, daß eine Mustererkennung nur auf der
Basis von Klassen und ihren hierarchischen Beziehungen ausreichend mächtig ist. Die
Mehrfachvererbung durch Interfaces scheint dabei nicht ernsthaft zu verwirren; sie kann sogar
elegant ausgenützt werden.
jag war leicht zu implementieren und sollte leicht zu erlernen sein, da nur sehr wenige
Prinzipien beachtet werden müssen. Ein Compiler ist Interpretern deutlich überlegen, dies
zeigt insbesondere das relativ realistische Beispiel der Register-Maschine.
Die mögliche Serialisierung der Code-Generatoren erlaubt eine weitgehende Entkopplung von
Frontend und Backend eines Compilers. Die Regelbasierung sollte es möglich machen,
jag-Traversierer für eine Knoten-Bibliothek zu implementieren, für die dann unabhängig
Frontends entwickelt werden können.
Die Register-Maschine zeigt, daß man prinzipiell semantische Aspekte in die
Code-Generierung hineinziehen könnte, aber das sollte man eigentlich schon früher im
Compiler berücksichtigen.
Das Testverfahren kann man natürlich durch Shell-Skripte und Vergleich mit früheren
Ausführungen verfeinern (regression testing).
120
Reguläre Ausdrücke — re/Re.java
Auf UNIX-Plattformen existieren normalerweise Kommandos und Funktionen zur Suche mit
Textmustern, den sogenannten regulären Ausdrücken. Der Begriff bezieht sich darauf, daß
diese Textmuster mit endlichen Automaten implementiert werden können, die ihrerseits zu
sogenannten regulären Grammatiken korrespondieren.
grep-Muster
erkennen Teilausdrücke, erlauben keine Alternativen, wurden
ursprünglich mit Backtracking implementiert.
egrep-Muster
erkannten ursprünglich keine Teilausdrücke, erlauben Alternativen,
werden durch Berechnung und anschließenden Betrieb des
deterministischen(!) endlichen Automaten implementiert.
egrep
egrep
egrep
abc...
\x
.
[abd-x...]
[^abd-x...]
^
$
die meisten Zeichen stellen sich selbst dar
ein zitiertes Zeichen stellt sich unbedingt selbst dar
ein beliebiges Zeichen
ein Zeichen aus einer Klasse
ein Zeichen nicht aus einer Klasse
Treffer an Textanfang
Treffer an Textende
x*
x+
x?
null oder mehrmals
ein oder mehrmals
optional: null oder einmal
xy
x|y
(...)
nacheinander
alternativ
Vorrang steuern
In diesem Abschnitt wird eine Implementierung von regulären Ausdrücken für Java skizziert,
die auf paralleler Interpretation des nicht-deterministischen Automaten beruht und über eine
Ablaufverfolgung verfügt. Die Lösung geht letztlich auf einen Artikel von Richards zurück, der
diesen Ansatz in BCPL implementierte.
121
Analyse
public class Re {
public static void main (String args [])
erlaubt die Optionen -a oder --anchored und -d
oder --debug und benötigt ein Textmuster. Die
anschließend angegebenen Dateien oder die
Standard-Eingabe werden nach diesem Muster
durchsucht.
protected boolean debug;
public Re (String pattern)
wird im Hauptprogramm gesetzt, um die
Ablaufverfolgung auszulösen.
verwendet rekursiven Abstieg und konstruiert
einen Musterbaum aus einem String mit einem
Textmuster.
public boolean anchoredMatch (String text)
untersucht, ob text vollständig dem Textmuster
genügt.
public boolean match (String text)
untersucht, ob ein Teil von text dem Textmuster
genügt.
protected static class Chars
dient zur lexikalischen Analyse, gibt einen String
zeichenweise ab.
protected final Alt re (Chars c)
erkennt seq [{ "|" seq }];
protected final Seq seq (Chars c)
erkennt item [{ item }];
protected final Node item (Chars c)
erkennt atom ["*"|"?"|"+"];
protected final Node atom (Chars c)
erkennt "^" | "$" | "." | x | "\"x | "[ ]" |
"[^ ]" | "(" re ")";
122
Automat
Der Zustand des endlichen Automaten besteht aus allen aktiven Musterknoten, die das
nächste Eingabezeichen erkennen könnten. match() versucht zu erkennen, mit enter()
werden im Erfolgsfall die unmittelbar nachfolgenden Knoten informiert. Verschiedene Bits
sorgen für effiziente Traversen.
protected abstract static class Node {
ermöglicht
protected Activate parent;
Rückkehr zur Wurzel.
protected boolean active;
markiert aktiven Knoten.
protected boolean entered;
markiert, daß Knoten enter() schon erhielt.
public final void setActive ()
falls !active: aktiviert Knoten und Pfad zur
Wurzel.
public void clearActive ()
top-down Traverse, löscht active.
public void clearEntered ()
top-down Traverse, löscht entered.
public abstract boolean enter ()
falls !entered, setzt entered, aktiviert Blatt,
traversiert Unterbaum, liefert dann true, falls
Nachfolger enter() erhalten soll.
public abstract boolean match (int c)
falls active, löscht active, vergleicht Eingabe und
liefert true, falls Nachfolger enter() erhalten soll.
public static final int BEG = -1, END = -2;
repräsentieren Eingabe von Textanfang und -ende.
}
Damit liegt fest, welche Methoden in den verschiedenen Musterknoten-Klassen
implementiert werden müssen, zum Beispiel:
protected static class Any extends Node {
public boolean enter () {
if (entered) return false; entered = true;
this.setActive();
return false;
}
public boolean match (int c) {
if (!active) return false; active = false;
if (c == BEG) this.setActive();
// stay active past BEG
return c >= 0;
// false on BEG/END
}
}
123
Treiber
enter() aktiviert die Nachfolger. entered begrenzt die Tiefe dieser Traverse. Vor jedem
match() muß dieses Bit gelöscht werden. patternActive wird im Zuge von enter() und
setActive global gesetzt; ist dieses Bit gelöscht, kann nichts mehr gefunden werden.
public boolean anchoredMatch (String text) {
if (debug) System.out.println("anc\t"+pattern.toString());
// entered, active unknown
int n = 0;
patternActive = false; pattern.clearActive(); pattern.clearEntered();
// entered, active false
boolean result = pattern.enter();
// entered unknown, active set
if (patternActive && usedBeg) {
if (debug) System.out.println("\t"+pattern.active()+"\nbeg");
patternActive = false; pattern.clearEntered();
// entered false, active set
result = pattern.match(Node.BEG);
// entered unknown, active set
}
for (; patternActive && n < text.length(); ++ n) {
if (debug) System.out.println("\t"+pattern.active()+"\n"+text.charAt(n));
patternActive = false; pattern.clearEntered();
// entered false, active set
result = pattern.match(text.charAt(n));
// entered unknown, active set
}
if (patternActive && usedEnd && n >= text.length()) {
if (debug) System.out.println("\t"+pattern.active()+"\nend");
patternActive = false; pattern.clearEntered();
// entered false, active set
result = pattern.match(Node.END);
}
if (debug) System.out.println("\t"+pattern.active());
return result && n >= text.length();
}
Die freie Suche ist erfolgreich beendet, sobald match() oder enter() Erfolg haben. Außerdem
wird vor jedem Zeichen das gesamte Muster durch Aufruf von enter() zusätzlich neu
aktiviert.
124
Beispiele
$ java re.Re --anchored --debug ’axel’
axel
anc
(axel)
a
a
x
x
e
e
l
l
true
Bei der verankerten Suche wird das Muster nur einmal aktiviert. Bei einfachen Texten muß
jedes Eingabezeichen den nachfolgenden Knoten aktivieren.
$ java re.Re debug ’xa|yb’
axbyb
((xa)|(yb))
x
y
a
x
y
x
xa
y
b
x
y
y
x
yb
b
true
Bei der freien Suche wird das Muster immer wieder zusätzlich aktiviert. Bei Alternativen muß
jede Alternative aktiviert werden.
Fazit
Die Ablaufverfolgung illustriert, daß eigentlich alle möglichen Wege durch das Muster parallel
verfolgt werden. Der deterministische Automat hat als Zustände Mengen von einfachen
Musterpositionen. Die Leistung von Programmen wie egrep oder lex besteht darin, diesen
Automaten zu optimieren.
Die objektorientierte Implementierung verteilt das Problem der Nachfolger-Information elegant
auf die einzelnen Klassen. Die vorliegende Lösung ist aber ineffizient.
Herunterladen