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.