9 Theoretische Informatik und Compilerbau Theoretische Informatik und Mathematik schaffen die Basis für viele der technischen Entwicklungen, die wir in diesem Buch besprechen. Die boolesche Algebra (S. 409 ff.) legt die theoretischen Grundlagen für den Bau digitaler Schaltungen, die Theorie formaler Sprachen zeigt auf, wie die Syntax von Programmiersprachen aufgebaut werden sollte, damit Programme einfach und effizient in lauffähige Programme übersetzt werden können, und die Theorie der Berechenbarkeit zeigt genau die Grenzen des Berechenbaren auf. Sie zieht eine klare Linie zwischen dem was man prinzipiell programmieren kann und dem was mit Sicherheit nicht von einem Rechner gelöst werden kann. In diesem Kapitel wollen wir einen kurzen Ausflug in die theoretische Informatik unternehmen. Wir werden sehen, dass diese Theorie nicht trocken ist, sondern unmittelbare praktische Anwendungen hat. Die Automatentheorie zeigt, wie man effizient die Wörter einer Programmiersprache festlegen und erkennen kann, die Theorie der kontextfreien Sprachen zeigt, wie man die Grammatik einer Programmiersprache definieren sollte, und wie man Übersetzer und Compiler dafür bauen kann. Letzterem haben wir ein eigenes Unterkapitel Compilerbau gewidmet. Ein fehlerfreies Programm ist aber noch lange nicht korrekt. Wünschenswert wäre es, wenn man feststellen könnte, ob jede Schleife auch terminiert. Dass diese und ähnliche semantischen Eigenschaften nicht automatisch geprüft werden können, ist eine der Konsequenzen der Berechenbarkeitstheorie. Mit dieser kann man zeigen, dass, wenn man einmal von Geschwindigkeit und Speicherplatz absieht, alle Rechner in ihren mathematischen Fähigkeiten identisch sind und damit das gleiche können bzw. nicht können. Schließlich hilft uns die Komplexitätstheorie, Aussagen über den Aufwand zu machen, den man zur Lösung wichtiger Probleme treiben muss. 9.1 Analyse von Programmtexten Programme sind zuerst einmal Texte, also Folgen von Zeichen aus einem gewissen Alphabet. Die ersten Programmiersprachen erlaubten nur Großbuchstaben, Klammern und Ziffern. In Pascal und C sind auch Kleinbuchstaben und Sonderzeichen, wie _ , +, ( , ), >, : , möglich und in Java sind sogar Unicode-Zeichen, insbesondere auch ä, ö und ü erlaubt. Die Menge aller Zeichen, die in einem Programm einer gewissen Programmiersprache vorkommen dürfen, 668 9 Theoretische Informatik und Compilerbau nennt man ihr Alphabet. Aus diesem Alphabet definiert man zunächst einmal die Wörter, aus denen die Sprache aufgebaut werden soll. Im Falle von Java oder Pascal sind dies u.a. Schlüsselwörter (while, do, for, if, else, ...), Sonderzeichen (+, -, *, <=, >= , ... ), benutzerdefinierte Bezeichner ( testFunktion, betrag, _anfangsWert, x37, r2d2 ) und Konstanten. Unter letzteren unterscheidet man noch Integer-Konstanten ( 42, 386, 2004) von Gleitkommazahlen (3.14, 6.025e23, .5 ). Ein Compiler für eine Programmiersprache muss als erstes prüfen, ob eine vorgelegte Datei ein syntaktisch korrektes Programm enthält. Ihm liegt der Quelltext als String, also als eine Folge von Zeichen vor. Die Analyse des Textes zerfällt in zwei Phasen – die lexikalische Analyse und die syntaktische Analyse. Dies entspricht in etwa auch unserem Vorgehen bei der Analyse eines fremdsprachlichen Satzes: In der ersten Phase erkennen wir die Wörter, aus denen der Satz besteht – vielleicht schlagen wir sie in einem Lexikon nach – und in der zweiten Phase untersuchen wir, ob die Wörter zu einem grammatikalisch korrekten Satz zusammengefügt sind. 9.1.1 Lexikalische Analyse Die erste Phase eines Compilers nennt man lexikalische Analyse. Dabei wird der vorgebliche Programmtext in Wörter zerlegt. Alle Trennzeichen (Leerzeichen, Tab, newLine) und alle Kommentare werden entfernt. Aus einem einfachen PASCAL-Programm, wie PROGRAM ggT; BEGIN x := 54; y := 30; WHILE not x = y DO IF x > y THEN x := x-y ELSE y:= y-x; writeln(’Das Ergebnis ist: ’,x) END . wird dabei eine Folge von Token. Dieses englische Wort kann man mit Gutschein übersetzen. Für jede ganze Zahl erhält man z.B. ein Token num, für jeden Bezeichner ein Token id, für jeden String ein Token str. Gleichartige Token stehen für gleichartige Wörter. Andere Token, die in dem Beispielprogramm vorkommen, sind eq (=) , gt (>), minus (-), assignOp( := ), klAuf ( ( ), klZu( ) ), komma ( , ), semi (;), punkt (.). Jedes Schlüsselwort bildet ein Token für sich. Nach dieser Zerlegung ist aus dem Programm eine Folge von Token geworden. Damit ist die erste Phase, die lexikalische Analyse, abgeschlossen. Im Beispiel hätten wir: program id semi begin id assignOp num semi id assignOp num semi while not id eq id do if id gt id then id assignOp id minus id else id assignOp id minus id semi id klAuf str komma id klZu end punkt 9.1 Analyse von Programmtexten 669 Aus dem Input erzeugte Token x:=0; do num id <= ; while num := id begin BEGIN Jetziger Zustand: while x<= q 42 do x:=x+2END bereits gelesener Input Abb. 9.1: Endlicher Automat als Scanner Diese erste Phase ist nicht schwer, aber auch nicht trivial. Es muss u.a. entschieden werden, ob BeGin das gleiche ist, wie BEGIN, ob beginnen ein id ist, oder das Schlüsselwort begin gefolgt von dem Bezeichner nen. Schließlich muss erkannt werden, ob 00.23e001, .523e, 314.e-2 das Token num repräsentieren, oder nicht. Programme für die lexikalische Analyse heißen Scanner. Heutzutage kann man solche Scanner aus einer Beschreibung der Token durch sogenannte reguläre Ausdrücke automatisch erzeugen. Der bekannteste frei erhältlicher Scannergenerator heißt flex. Die lexikalische Analyse ist das Thema des ersten Unterkapitels. 9.1.2 Syntaxanalyse Wir nehmen an, dass unser Programm die lexikalische Analyse gut überstanden hat. Dann folgt als nächstes die sogenannte Syntaxanalyse. Hier wird geprüft, ob die gefundenen Wörter in der vorliegenden Reihenfolge ein grammatikalisch korrektes Programm bilden. Ausdrücke wie Wörter, Sprache, Grammatik, etc. erinnern nicht zufällig an natürliche Sprachen. Auch eine Analyse eines fremdsprachigen Satzes erfordert zunächst seine Zerlegung in Wörter und deren Klassifikation. Dabei könnte man vielleicht Token wie verb, nomen, artikel, adjektiv, komma, punkt benutzen. Aus einem Satz wie beispielsweise Die lila Kuh legt ein Schokoladenei. hätte die lexikalische Analyse die Tokenfolge artikel adjektiv nomen verb artikel nomen punkt abgeliefert. In der syntaktischen Analyse muss jetzt anhand der Regeln der Sprache überprüft werden, ob diese Folge von Token zulässig ist. Hier würde man Bildungsregeln der deutschen Sprache heranziehen, wie (stark vereinfacht): 670 9 Theoretische Informatik und Compilerbau Satz Subjekt Prädikat Objekt :: :: :: :: Subjekt Prädikat Objekt artikel nomen | artikel adjektiv nomen verb | hilfsverb artikel nomen Ganz analog sehen die Bildungsregeln einer Programmiersprache aus. Im Beispiel von Pascal hat man u.a.: Programm Kopf Anweisungsteil Anweisungen :: :: :: :: Kopf Deklarationen AnweisungsTeil punkt program id semi begin Anweisungen end Anweisung | Anweisungen Anweisung In der Syntaxanalyse überprüft man, ob eine Folge von Token diesen Regeln entsprechen. Diesen Überprüfungsprozess nennt man auch Parsen (engl.: to parse = zerlegen). Wie man Regeln für eine Programmiersprache sinnvollerweise festlegt, und wie man einen Parser dafür schreiben und sogar automatisch erzeugen kann, davon handelt der zweite Teil dieses Kapitels. 9.2 Reguläre Sprachen Zuerst erkennen wir eine Gemeinsamkeit von lexikalischer und syntaktischer Analyse. Erstere betrachtet Wörter als Folge von Zeichen und fragt, ob ein Wort zu einer Klasse von Wörtern (num, float, id) gehört, letztere betrachtet Wörter als Folge von Token und fragt ebenfalls, ob das zusammengesetzte Wort zur Klasse der nach gewissen Regeln korrekt aufgebauten Programme gehört. Abstrakt definieren wir einfach: Definition (Alphabet): Ein Alphabet ist eine endliche Menge von Zeichen. Alphabete bezeichnet man gern mit großen griechischen Buchstaben wie Σ oder Γ und Zeichen mit a,b,c, etc. Aus den Zeichen eines Alphabets können wir Wörter bilden, daher folgt sogleich die nächste Definition: Definition (Wort): Ein Wort über einem Alphabet Σ ist eine endliche Folge von Zeichen aus Σ . Die Menge aller Wörter über Σ bezeichnet man mit Σ* . Wörter bezeichnen wir meist mit den Buchstaben u, v, w. Eine besondere Rolle spielt das leere Wort, es wird mit ε bezeichnet. Wörter über Σ sind nichts anderes als Strings, deren Zeichen aus Σ stammen. Als Konkatenation bezeichnet man die Zusammenfügung zweier Wörter u und v zu einem neuen Wort, das man als uv bezeichnet. Für jedes Wort u gilt offensichtlich: εu = uε = u . Die Länge eines Wortes w ist die Anzahl der Zeichen, aus denen w besteht. Man schreibt |w| für die Länge von w. Offensichtlich gilt: | ε |=0 und |uw| = |u|+|w|. Ein Zeichen kann man auch 9.2 Reguläre Sprachen 671 als Wort der Länge 1 auffassen. Daher benutzen wir die Notation aw auch für das Wort, das aus w entsteht, wenn man das Zeichen a davorsetzt, analog versteht man wa. Beispiele: Sei Σ die Menge aller Ziffern, und Γ die Menge aller Großbuchstaben, dann sind 42, 2004, 0, ε Elemente von Σ* und ε , C, ML, LISP sind Elemente von Γ* . Weiter sind R2D2, MX5 und X86 Elemente von ( Σ ∪ Γ )* , nicht aber von Σ* ∪ Γ* . Definition (Sprache): Eine Sprache über dem Alphabet Σ ist eine Menge von Wörtern über Σ . Eine Sprache über Σ ist also einfach eine beliebige Teilmenge von Σ* . Insbesondere ist auch die leere Menge { } eine Sprache, ebenso wie Σ* . Für Sprachen benutzt man gerne den Buchstaben L, weil dieser an das englische Wort language erinnert. Die folgenden Beispiele sind relevant und motivierend für die Entwicklung der Theorie: Beispiel 1: Die Sprache der Pascal-Bezeichner. Als Alphabet hat man die Ziffern, die Buchstaben (ohne Umlaute) und den Unterstrich, also Σ ={_ , 0, ... , 9, a, ... , z, A, ..., Z}. Die Sprache aller gültigen Pascalbezeichner ist L = { au u ∈ Σ* , a ∉ { 0, … , 9 } } , also alle nichtleeren Wörter, die nicht mit einer Ziffer beginnen. Beispiel 2: Die Sprache aller dezimalen Konstanten in Assembler. Dezimale Konstanten sind alle Wörter, die optional mit einem Vorzeichen beginnen, gefolgt von einer Folge von Ziffern. Sie dürfen nicht mit 0 beginnen außer wenn keine weitere Ziffer folgt. (In Assembler wird 012 als Hex-Zahl interpretiert.) Als Alphabet wählen wir Γ = Digit ∪ Sign mit Digit = { 0, 1, …, 9 } und Sign = { – , + } . Wir definieren zuerst eine Hilfssprache L nat = { au a ∈ Digit – { 0 }, u ∈ Digit* } ∪ { 0 } und dann schließlich L DAss = L nat ∪ { au a ∈ Sign ,u ∈ L nat } . Mit einem kleinen Trick hätten wir die Definition kompakter gestalten können: Mit Hilfe des leeren Wortes definieren wir uns eine Sprache OptSign = { +, – , ε } . Dann haben wir L DAss = { uv u ∈ OptSign, v ∈ L nat } . Offensichtlich lässt die bisher verfügbare mathematische Notation noch Wünsche offen. Um z.B. die Sprache aller float-Zahlen auf die obige Weise in Java auszudrücken, müsste man sich schon anstrengen. Wir erinnern, dass u.a. die folgenden Zahlen gültige float sind: -3.14, 2.7f, 2.7, .3, 1e-9F, aber auch 00.5E000F. 9.2.1 Reguläre Ausdrücke Um komplizierte Sprachen aus einfacheren aufbauen zu können, definieren wir uns die Operationen, mit denen wir aus gegebenen Sprachen neue konstruieren können: Seien L und M Sprachen über dem gemeinsamen Alphabet Σ . Wir definieren L ⋅ M = { uv 0 u ∈ L, v ∈ M } , das Produkt von L und M, L = { ε }, L L* = ∪{ L n+1 n n = L ⋅ L ,die Potenzen von L, n ∈ Nat } , der Kleene-Stern von L. 672 9 Theoretische Informatik und Compilerbau Zusammen mit den bekannten mengentheoretischen Operationen daraus weitere nützliche Operatoren gewinnen: • • ∪ und ∩ können wir + L = L ⋅ ( L* ) , mehrmals L, L? = L ∪ { ε } , optional L. Mit den gerade eingeführten Operationszeichen bauen wir uns zunächst eine Notation, in der wir die betrachteten Sprachen gut beschreiben können. Definition (regulärer Ausdruck): Sei Σ ein Alphabet. • • • ∅ und ε sind reguläre Ausdrücke. a ist ein regulärer Ausdruck für jedes a ∈ Σ . Sind e und f reguläre Ausdrücke, dann auch e + f , e ⋅ f und e* . Zur eindeutigen Darstellung erlauben wir Klammern „(„ und „)“. Zur Klammernersparung vereinbaren wir, dass * starker bindet als ⋅ , und dieses stärker als + . Jeder reguläre Ausdruck soll eine Sprache beschreiben. Mit L(e) bezeichnen wir die Sprache, die durch den regulären Ausdruck e beschrieben wird. Die Definition folgt dem induktiven Aufbau der regulären Ausdrücke: L ( ∅ ) = { } und L ( ε ) = { ε } . L ( a ) = { a } für jedes a ∈ Σ . L( e + f) = L( e) ∪ L( f) , L ( e ⋅ f ) = L ( e ) ⋅ L ( f ) und L ( e* ) = L ( e )* . Man beachte den feinen Unterschied zwischen der leeren Sprache { } und der Sprache, die nur das leere Wort enthält, { ε } . Mit regulären Ausdrücken könnten wir jetzt die vorhin diskutierten Sprachen beschreiben. Beispielsweise gilt Lnat = (1+2+3+4+5+6+7+8+9) ⋅ (0 +1+2+3+4+5+6+7+8+9)* + 0. Die eingeführte Notation ist noch nicht kompakt genug, daher definieren wir einige Abkürzungen: e? := e+ ε e+ := e ⋅ e* drückt aus, dass e optional ist mehrmals e, mindestens einmal. Teilmengen des Alphabets dürfen wir auch aufzählen, z.B. [ä,ü,ö] statt (ä+ü+ö). Meist sind die Zeichen des Alphabets geordnet. Für diesen Fall definieren wir die Abkürzung [a – c] für { x ∈ Σ | ( a ≤ x ≤ c ) } . Das Konkatenationszeichen ⋅ lässt man oft weg. So wird aus den vorigen Beispielen: nat := DAss := letter := PascalBezeichner := [1 – 9][0 – 9]* + 0 [+, - ]? nat [a – z]+[A – Z], digit := [0 - 9], ( _ + letter)( _ + letter+digit)* 9.2 Reguläre Sprachen 673 Die so eingeführte reguläre Sprache wird in vielen Unix-Tools (sed, grep, awk) und in vielen Sprachen (Perl, Tcl/Tk) praktisch eingesetzt, vgl. auch Abschnitt 6.4.14 auf S. 536. Viele Editoren erlauben Textsuche nach beliebigen Zeichenketten mit Hilfe eines regulären Ausdrucks. Reguläre Ausdrücke werden praktisch immer verwendet, um den lexikalischen Anteil von Programmiersprachen festzulegen. So auch in dem Werkzeug lex (bzw. flex), mit dessen Hilfe automatisch ein C-Programm generiert wird, das die lexikalische Analyse für eine selbstdefinierte Programmiersprache übernimmt und somit die erste Stufe eines Compilers bildet. Die folgenden Zeilen zeigen zwei Einträge aus einer flex-Datei. Es werden Bezeichner und Dateinamen definiert. Der Schrägstrich ’/’ hat für flex eine Spezialbedeutung. Durch das vorangestellte Escape-Zeichen ’\’ wird diese aber aufgehoben. [_a-zA-Z][a-zA-Z0-9]* [a-zA-Z0-9\/.-]+ return BEZEICHNER; return FILENAME; Im folgenden Paragraphen wollen wir uns der Frage widmen, wie Werkzeuge wie lex funktionieren können. Konkret: Wie kann man ein Programm erstellen, das zu einem beliebigen regulären Ausdruck e und einem beliebigen Text s erkennt, ob s ∈ L ( e ) ist, oder das alle Textstellen findet, die auf den regulären Ausdruck passen. 9.2.2 Automaten und ihre Sprachen Ein Automat ist ein sehr einfaches Modell einer zustandsorientierten Maschine. Wir stellen uns einen Kasten vor, der eine Eingabe erhält und daraufhin seinen internen Zustand ändert. Damit das ganze einen Nutzeffekt hat, soll der Automat auch eine Ausgabe haben, für unsere Zwecke wird es aber ausreichen, wenn wir uns auf zwei mögliche Ausgaben beschränken – ja oder nein, true oder false, Licht an oder Licht aus. Als Eingabe des Automaten A verwenden wir ein Alphabet Σ . Wir gehen davon aus, dass der Automat eine Menge Q von möglichen Zuständen einnehmen kann. Mit einer Reset-Taste können wir ihn in einem wohldefinierten Ausgangszustand starten. Danach geben wir Zeichen aus Σ ein. Jedes eingegebene Zeichen kann den Automaten in einen neuen Zustand versetzen. Ist q der gegenwärtige Zustand und geben wir ein Zeichen a ein, so sei δ ( q, a ) der neue Zustand. Einen Zustand, der die Ausgabe true erzeugt, nennen wir einen finalen oder akzeptierenden Zustand. Die formale Definition ist: Definition (Automat) Σ sei ein endliches Alphabet. Ein Σ -Automat besteht aus einer Menge Q von Zuständen und einer Übergangsfunktion δ: Q × Σ → Q . Ein spezieller Zustand q 0 ∈ Q dient als Anfangszustand und F ⊆ Q sei eine Menge von finalen oder akzeptierenden Zuständen. Beispiel: Eine Funk-Digitaluhr habe nur zwei Knöpfe: mode, lap. Wir können diese als Alphabet Σ = { mode, lap } auffassen. Die internen Zustände seien Uhr, Timer, Pause und die Zustandsübergänge etwa δ ( Uhr ,mode ) = Pause , δ ( Pause ,mode ) = Uhr δ ( Pause, lap ) = Timer und δ ( Timer, lap ) = Pause , δ ( Uhr ,lap ) = Uhr , etc. Uhr sei der Anfangszustand. Die finalen Zustände eines Joggers seien: {Timer, Pause}. 674 9 Theoretische Informatik und Compilerbau Beispiel: Ein Erkenner (engl.: scanner) für Pascal-Bezeichner habe als Alphabet das ASCIIAlphabet und als Zustände: {Start, OK, Error} mit δ ( Start, b ) = OK , für jeden Buchstaben (engl.: letter) b und δ ( OK, c ) = OK für jeden Buchstaben oder Ziffer (engl.: digit) c. (Wir rechnen einfach „_“ zu den Buchstaben.) In jedem anderen Fall sei δ ( q, a ) = Error . Automaten kann man veranschaulichen, indem man jeden Zustand q durch einen Kreis darstellt und jeden Übergang δ ( q, a ) = q' durch einen Pfeil von q nach q' , den man mit a beschriftet. Zwischen zwei Zuständen malt man höchstens einen Pfeil und beschriftet ihn mit allen Zeichen, die diesen Zustandsübergang hervorrufen. Auf den Anfangszustand zeigt ein Pfeil, der aus dem Nichts kommt. Akzeptierende Zustände kennzeichnet man durch eine doppelte Umrandung. Für jedes Zeichen a muss aus jedem Zustand q ein Pfeil ausgehen, der mit a beschriftet ist. Allerdings ist diese Bedingung manchmal lästig. Man kann sie aber immer erfüllen, wenn man einen Errorzustand E hinzunimmt, zu dem alle fehlenden Pfeile gerichtet werden. In der Figur haben wir diese durch gestrichelte Pfeile hervorgehoben. Später werden wir diese weglassen. digit digit s0 letter, digit S letter Ok +, - digit s1 . Err s5 s4 digit s2 . s3 digit Abb. 9.2: Automaten für Bezeichner und für Dezimalzahlen Wie das letzte Beispiel andeutet, kann man Automaten dazu benutzen, Wörter zu akzeptieren oder zurückzuweisen. Dazu startet man sie im Anfangszustand q 0 und gibt der Reihe nach die Zeichen c 1, c 2, c 3, … eines Wortes w ein. Der Automat geht dabei durch die Zustände q 0, q 1 = δ ( q 0, c 1 ), q 2 = δ ( q 1, c 2 ), … Ist er zum Schluss, wenn das Wort w komplett abgearbeitet ist, in einem akzeptierenden Zustand, so wird das Wort akzeptiert, ansonsten zurückgewiesen. Man kann ein Wort w = c 1 c 2 c 3 …c n als eine Art Fahrplan durch den Automaten auffassen. Am Anfangsknoten q 0 ist dem Pfeil zu folgen, der mit c 1 beschriftet ist, im Folgezustand q 1 = δ ( q 0, c 1 ) dem Pfeil mit Beschriftung c 2 , etc. Die Folge der dabei besuchten Zustände nennt man auch einen Lauf für w. 9.2 Reguläre Sprachen q0 Abb. 9.3: c1 q1 675 c2 q2 qn-1 cn qn Lauf für w = c1 c2 c3 ... cn Somit interessiert uns hauptsächlich, welche Wirkung ein ganzes Wort auf den Automaten hat. Daher erweitern wir die Funktion δ zu einer Funktion δ: Q × Σ* → Q durch δ* ( q, ε ) = q δ* ( q, aw ) = δ* ( δ ( q, a ), w ) für jedes Wort w und Zeichen a. Jeder Automat definiert auf diese Weise eine Sprache, nämlich die Menge aller Wörter, die uns vom Anfangszustand in einen Endzustand führen: Definition (Sprache eines Automaten): Sei A ein Σ -Automat mit Anfangszustand q 0 , Transitionsfunktion δ und akzeptierenden Zuständen F ⊆ Q , dann heißt L ( A ) = { w ∈ Σ* | δ* ( q 0, w ) ∈ F } die Sprache des Automaten A. Die Sprache des linken Automaten in Abbildung 9.2 besteht aus allen Wörtern, die mit einem Buchstaben (letter) beginnen, auf den dann beliebig viele Buchstaben oder Ziffern folgen. Die Sprache des rechten Automaten ist die Menge aller Dezimalzahlen. 9.2.3 Implementierung endlicher Automaten Für alle uns interessierenden Anwendungen werden die Automaten nur endlich viele Zustände haben. Für solche endlichen Automaten kann man die Abbildung δ: Q × Σ → Q durch eine |Q| × |Σ| -Tabelle mit Einträgen aus Q implementieren. Für q ∈ Q und a ∈ Σ findet man δ ( q, a ) in der q-ten Zeile und der a-ten Spalte. Die Abbildung δ* wird durch eine einfache for-Schleife realisiert: Zustand deltaStern(Zustand z,String wort){ for(int i=0;i<wort.length;i++) z = tabelle[z][wort.charAt(i)]; return z; } und die Endzustände durch ihre charakteristische Funktion: boolean isFinal(Zustand z){ ... } Für Sprachen, die durch endliche Automaten beschrieben werden, ist ein Scanner also einfach zu implementieren. Allerdings wird, wie bereits gesagt, der lexikalische Anteil von Programmiersprachen meist durch reguläre Ausdrücke spezifiziert. Wie kann man aber den regulären Ausdruck in einen Automaten umwandeln? Leider geht dies nicht direkt, sondern nur auf dem Umweg über sogenannte nichtdeterministische Automaten. 676 9 Theoretische Informatik und Compilerbau 9.2.4 ε -Transitionen und nichtdeterministische Automaten ε -Transitionen sind Zustandsübergänge, die ein Automat machen kann, ohne dass ein Eingabezeichen verbraucht wird. Wir erweitern unser Automatenkonzept, indem wir jetzt erlauben, dass Transitionen eingefügt werden, die wir mit ε beschriften. Von einem Zustand q dürfen keine, eine oder mehrere solche ε -Transitionen ausgehen. Ein Lauf für ein Wort w = c 1 c 2 c 3 …c n ist jetzt eine Zustandsfolge q 0, q 1, q 2, … , q k bei der in jedem Schritt entweder ein Zeichen c i verbraucht wird oder einer ε -Transition gefolgt wird. Wir wollen uns die formale Definition hier sparen und veranschaulichen die Situation graphisch: c1 q0 q1 ε q2 ε q3 c2 qn-1 q4 cn qn Lauf mit ε-Transitionen Abb. 9.4: Ein Wort w = c 1 c 2 …c n ist nun kein eindeutiger Fahrplan mehr. Ist man nach i Schritten zum Zustand q i gelangt, dann können wir entweder einem mit c i + 1 beschrifteten Pfeil oder einer ε -Transition folgen, sofern eine solche vorhanden ist. Jetzt sagt man, dass ein Wort akzeptiert wird, wenn es möglich ist, mit diesem Wort in einen akzeptierenden Zustand zu kommen. Für einen Automaten A mit ε -Transitionen definieren wir also die Sprache L(A), die durch den Automaten A erkannt wird als L ( A ) = { w ∈ Σ* | Es gibt einen w – Lauf zu einem akzeptierenden Zustand. } Meist führt man an dieser Stelle noch sogenannte nichtdeterministische Automaten ein. Sie erlauben, dass für beliebige Zeichen a ∈ Σ aus jedem Zustand keiner oder mehrere Pfeile mit Beschriftung a führen können. Für unsere Zwecke benötigen wir diese allgemeinere Automatendefinition aber nicht. 9.2.5 Automaten für reguläre Sprachen Für jeden regulären Ausdruck e wollen wir jetzt einen Automaten Ae mit ε -Transitionen konstruieren, der genau die gleiche Sprache erkennt, also mit L ( e ) = L ( A e ) . Wir folgen dem induktiven Aufbau der regulären Ausdrücke. Dabei konstruieren wir in jedem Schritt sogar Automaten mit genau einem akzeptierenden Zustand, der verschieden ist vom Anfangszustand. Für die regulären Ausdrücke ∅ , ε und a ∈ Σ wählen wir: ε Abb. 9.5: ε ε Ae Af a Automaten für ∅ , ε und a ∈ ∑ . ε ε Ae ε ε Af ε ε Ae ε 9.2 Reguläre Sprachen 677 Für die regulären Operatoren +, ⋅ und * gehen wir jeweils davon aus, dass wir für die regulären Ausdrücke e und f schon Automaten Ae und ε A f mit je einem Anfangs- aund Endzustand konstruiert haben. Wir fügen ggf. neue Anfangs- und Endzustände und ε -Transitionen hinzu und machen früher akzeptierende Zustände zu nicht akzeptierenden, um die Automaten für e + f , e ⋅ f und e* zu erhalten. ε ε Abb. 9.6: 9.2.6 Ae Af ε ε Ae ε Af ε ε Ae ε ε Automatenkonstruktionen für +,⋅ und *. Von nichtdeterministischen zu deterministischen Automaten Wir haben gesehen, wie man zu jedem regulären Ausdruck e einen Automaten A finden kann, der die durch e spezifizierte Sprache erkennt. Da der Automat aber ε -Übergänge haben kann, funktioniert unsere vorher gezeigte Implementierung von Automaten durch eine Tabelle nicht mehr unmittelbar. Daher müssen wir zuerst unseren ε -Automaten durch einen gleichwertigen deterministischen Automaten ersetzen. Die Potenzmengenkonstruktion, die wir hier vorstellen, funktioniert für jeden nichtdeterministischen Automaten. Sei also A ε unser Ausgangsautomat mit endlicher Zustandsmenge Q , Anfangszustand q 0 und Finalzuständen F ⊆ Q . Zu jedem Wort w ∈ Σ* sei Q ( w ) ⊆ Q die Menge aller Zustände, zu denen es einen w -Pfad von q 0 aus gibt. Wir konstruieren einen deterministischen endlichen Automaten D , der die gleiche Sprache erkennt wie A ε . Wir wählen • • • • Q als Zustandsmenge { Q ( w ) | w ∈ Σ* } . D hat also maximal 2 viele Zustände. als Anfangszustand Q ( ε ) und als finale Zustände alle Q ( w ) , mit F ∩ Q ( w ) ≠ ∅ , als Zustandsübergangsfunktion ϑ ( Q ( w ), a ) = Q ( wa ) für a ∈ Σ und w ∈ Σ* . Da aus Q ( w ) = Q ( w' ) folgt Q ( wa ) = Q ( w'a ) , ist ϑ wohldefiniert und es gilt: Hilfssatz: Für alle w, v ∈ Σ* ist ϑ* ( Q ( w ), v ) = Q ( wv ) . Beweis durch Induktion über den Aufbau der Wörter v ∈ Σ* : Ist v = ε , dann gilt nach Def von ϑ* sofort ϑ* ( Q ( w ), ε ) = Q ( w ) = Q ( wε ) . Ist v = au und sei für alle w ∈ Σ* die Behauptung ϑ* ( Q ( w ), u ) = Q ( wu ) schon gezeigt, insbesondere also auch ϑ* ( Q ( wa ), u ) = Q ( wau ) , dann rechnen wir: ϑ* ( Q ( w ), au ) = ϑ* ( ϑ ( Q ( w ), a ), u ) = ϑ* ( Q ( wa ), u ) = Q ( wau ) = Q ( wv ) . Satz: A ε und D erkennen die gleiche Sprache, kurz: L ( A ε ) = L ( D ) . 678 9 Theoretische Informatik und Compilerbau Beweis: w ∈ L ( D ) ⇔ δ* ( Q ( ε ), w ) ist final in D ⇔ Q ( w ) ist final in D (aufgrund des Hilfssatzes) ⇔ es gibt einen finalen Zustand q f ∈ Q ( w ) ⇔ in A ε gibt es einen w-Pfad zu einem finalen Zustand q f ∈ Q ⇔ w ∈ L ( Aε ) . Wir haben also einen deterministischen Automaten gefunden, der die gleiche Sprache erkennt wie A ε . Somit halten wir als Hauptergebnis fest: Satz: Zu jedem regulären Ausdruck e gibt es einen deterministischen endlichen Automaten A e , der die durch e spezifizierte Sprache erkennt. Diesen können wir mittels einer Tabelle implementieren. Die Umkehrung, dass es zu jedem endlichen Automaten A auch einen entsprechenden regulären Ausdruck gibt, wollen wir hier nicht beweisen. Für praktische Zwecke ist diese Richtung nicht so interessant. 9.2.7 Anwendung: flex Das bereits erwähnte Programm flex ist eine freie Variante des UNIX-Programms lex. Es akzeptiert als Eingabe einen oder mehrere reguläre Ausdrücke und erzeugt daraus ein C-Programm, das einen Scanner für die besagten regulären Ausdrücke implementiert. Mittlerweile gibt es auch Varianten von flex für andere Sprachen, u.a. für Pascal, perl, oder Java. flex funktioniert prinzipiell wie oben theoretisch beschrieben, allerdings transformiert es nicht nur einen, sondern gleich mehrere reguläre Ausdrücke in Automaten und dann das ganze in ein tabellengesteuertes Programm. Außerdem werden die gefundenen Automaten noch minimiert, indem äquivalente Zustände identifiziert werden. Zustände q und q' heißen dabei äquivalent, wenn für alle w ∈ Σ* gilt: δ* ( q, w ) ∈ F ⇔ δ* ( q', w ) ∈ F . Der von flex generierte Scanner liest einen String von der Eingabe (standard input) und versucht jeweils Anfangsstücke abzutrennen, die zu einem der regulären Ausdrücke gehören. Immer wenn er erfolgreich ist, kann er noch eine gewünschte Aktion ausführen. Die beiden Zeilen einer flex-Datei, die auf S. 673 gezeigt sind, geben jeweils ein Token in Form von benutzerdefinierten Konstanten BEZEICHNER, bzw. FILENAME zurück. Für solche Zwecke können im Vorspann einer flex-Sprachbeschreibung entsprechende Token definiert werden. Eine flex-Datei besteht aus drei Teilen, die durch %% getrennt sind. Im ersten Teil werden Abkürzungen für reguläre Ausdrücke definiert. Im zweiten Teil stehen auf jeder Zeile ein regulärer Ausdruck und die zugehörige Aktion als C-Anweisung. Die von flex erzeugte Scannerfunktion hat den Namen yylex(). Sie hat einen input-stream yyin, auf dem sie die Eingabedatei erwartet. Wenn immer ein Anfangsstück aus der Eingabe erkannt wurde, wird es in der 9.3 Kontextfreie Sprachen 679 Variablen yytext zwischengespeichert. Die Aktionen, der jeweils erkannten regulären Ausdrücke können jeweils auf diesen String zurückgreifen. Im folgenden zeigen wir eine komplette flex-Datei zeit.lxi. Sie soll alle Zeitangaben in einem Text finden und ausdrucken. Im Vorspann wird zeit als regulärerer Ausdruck mit den Hilfsausdrücken für stunde und minSec definiert Im Hauptteil stehen die regulären Ausdrücke zeit, ein Punkt „.“, und $. Letztere stehen für ein beliebiges Zeichen, bzw. für ein Zeilenende. stunde [01][0-9]|2[0-3] minSec [0-5][0-9] zeit {stunde}:{minSec}(:{min_sec})? %% {zeit} { printf("%s\n",yytext); } . { } $ { zeilenZahl++;} %% main( argc, argv){ int argc; int zeilenZahl=0; char **argv;{ yyin = stdin; yylex(); printf("%d Zeilen gelesen\n",zeilenZahl); } Im dritten Teil kann der Aufruf von yylex() in ein Hauptprogramm main() eingebettet werden. In diesem Falle erzeugt flex aus zeit.lxi einen lauffähigen Scanner. Dieser muss nur noch mit einem C-Compiler übersetzt und mit Funktionen der lex-library ll verlinkt werden: flex zeit.lxi | cc -ll > zeit.exe 9.3 Kontextfreie Sprachen Mit regulären Ausdrücken kann man die Wörter, aus denen Programmiersprachen aufgebaut sind, beschreiben. Technisches Ergebnis dieser Beschreibung kann ein Scanner sein, der einen Programmtext in Wörter zerteilt und für jedes gefundene Wort ein entsprechendes Token liefert. In der nächsten Phase muss entschieden werden, ob die entstandene Folge von Token ein korrektes Programm darstellt. Fasst man die Token als neues Alphabet Γ auf, so ist ein Programm ein Wort über diesem Alphabet. Die Menge aller korrekten Programme ist also eine Sprache über Γ . Die Frage liegt nahe, ob man diese Sprache auch durch reguläre Ausdrücke beschreiben kann. Die Antwort ist negativ, was man an dem folgenden Beispiel sieht: 680 9 Theoretische Informatik und Compilerbau Beispiel: Sei Σ = { ( , ) } das Alphabet, das aus einer öffnenden und schließenden Klammer besteht. Die Sprache KK, der korrekten Klammerausdrücke, bestehe aus allen Wörtern über Σ , die aus einem korrekten arithmetischen Ausdruck entstehen, wenn man alles, bis auf die Klammern, löscht. Beispielsweise gilt ( ) ( ( ( )( ) )( ) ) ∈ KK , dagegen ist z.B. ( ( ) ) ) ( ( ) ∉ KK . Angenommen, man könnte KK durch einen regulären Ausdruck beschreiben, dann gäbe es auch einen endlichen deterministischen Automaten, der KK erkennt. Geben wir diesem ein Wort ein, das mit k vielen öffnenden Klammern beginnt, also (((…( , so sei q k der erreichte Zustand. Für k ≠ k' muss aber q k ≠ q k' gelten, denn aus ersterem Zustand muss man mit k schließenden Klammern in einen akzeptierenden Zustand gelangen, aus dem zweiten darf man dies nicht. Mithin müsste der Automat unendlich viele Zustände haben. 9.3.1 Kontextfreie Grammatiken Klammerausdrücke kommen in allen Programmiersprachen vor, sei es in arithmetischen Ausdrücken, oder in Form von begin und end, { und }. Eine allgemeinere Notation für die Sprachdefinition bieten sogenannte kontextfreie Grammatiken. Wir zeigen zuerst als Beispiel eine Grammatik für eine einfache Sprache, die wir WHILE nennen wollen: Programm Anweisungen Anweisung Zuweisung Schleife Alternative Expr Bexpr :: :: :: :: :: :: :: :: begin Anweisungen end ε | Anweisung | Anweisung ; Anweisungen Zuweisung | Schleife | Alternative id := Expr while Bexpr do Anweisung if Bexpr then Anweisung Expr + Expr | Expr - Expr | Expr * Expr | ( Expr ) | id | num Expr = Expr | Expr < Expr | not Bexpr | true | false Der Lesbarkeit halber verwenden wir hier die Zeichen ;, :=, +, etc. anstatt der Tokennamen semi, assignOp, plus, minus, etc. Jede Zeile ist als Definition des Begriffes links von dem zweifachen Doppelpunkt zu verstehen. Auf der rechten Seite trennt der senkrechte Strich Alternativen, er ist als „oder“ zu lesen. ε bezeichnet wie bisher das leere Wort. Beispielsweise besagen die ersten beiden Zeilen: „Ein Programm beginnt mit dem Token begin, dann folgen Anweisungen dann das Token end“ und „Anweisungen sind leer oder bestehen aus einer Anweisung oder aus einer Anweisung gefolgt von einem „;“ und Anweisungen.“ WHILE ist also eine Sprache über dem Alphabet { begin, end, ; , id, := , while, do, if, then, +, -, *, ( , ), =, <, num, not, true, false }. Man nennt diese Zeichen Terminale oder Token. Als Nonterminale bezeichnet man die in der Grammatik definierten Begriffe wie Programm, Anweisungen, Anweisung, Zuweisung, Expr, Bexpr, etc. Definition: Eine kontextfreie Grammatik besteht aus einer Menge T von Terminalen (oder Token), einer dazu disjunkten Menge NT von Non-Terminalen, einer endlichen Menge von Produktionen P ⊆ NT × ( T ∪ NT ) * und einem Startsymbol S ∈ NT . Eine Produktion ist also ein Paar ( A, α ) bestehend aus einem A ∈ NT und einem Wort α ∈ ( T ∪ NT )* , das aus Terminalen und Nonterminalen bestehen kann. Ein solches Wort nennt man auch Satzform. Im Zusammenhang mit Grammatiken verwenden wir vorwiegend 9.3 Kontextfreie Sprachen 681 griechische Buchstaben α , β , γ für Satzformen, kursive Großbuchstaben A,B,C oder S für Nonterminale und Kleinbuchstaben a,b,c,t für Terminale. Statt ( A, α ) schreibt man auch A → α oder A :: α . Außerdem fasst man verschiedene Produktionen mit gleicher linker Seite zu einer sogenannten Regel zusammen, wobei man die rechten Seiten durch „|“ trennt. Eine Regel A :: α 1 α 2 … α n steht dann für die Menge { A → α 1, A → α 2, …, A → α n } von Produktionen. Wenn man eine Grammatik aufschreibt, versteht man das Nonterminal der ersten Produktion als Startsymbol. In unserer WHILE-Sprache ist also Programm das Startsymbol. 9.3.2 Ableitungen Wir beschäftigen uns als erstes damit, wie man aus einer Grammatik irgendwelche syntaktisch korrekte Programme ableiten kann. Das wird natürlich nicht unser endgültiges Ziel sein, denn später wollen wir herausfinden, wie man zu einem vorliegenden Programm feststellen kann, ob (und wie) es aus einer Grammatik abgeleitet werden kann. Eine Ableitung beginnt mit dem Startsymbol S und ersetzt jeweils ein Nonterminal A durch eine Satzform α , sofern A → α eine Produktion ist. Die Ableitung endet, wenn nur noch Terminale vorhanden sind. Im Beispiel unserer WHILE-Grammatik haben wir Programm ⇒ begin Anweisungen end ⇒ begin end als kürzestmögliche Ableitung einer terminalen Satzform. Im ersten Schritt wurde die Produktion Programm :: begin Anweisungen end benutzt, im zweiten: Anweisungen :: ε . Eine Ableitung eines anderen Programms ist z.B.: Programm ⇒ begin ⇒ begin ⇒ begin ⇒ begin ⇒ begin ⇒ begin Anweisungen end Anweisung ; Anweisungen end Zuweisung ; Anweisungen end id := Expr ; Anweisungen end id := num ; Anweisungen end id := num ; end Im Allgemeinen leitet man in jedem Schritt aus einer Satzform αAβ mithilfe einer Produktion A → γ die Satzform αγβ ab. Man notiert einen solchen Schritt als αAβ ⇒ αγβ . Verwendete Produktion: Anweisung → id := Expr begin Anweisung ; Anweisungen end Abb. 9.7: Ableitungsschritt ⇒ begin id := Expr ; Anweisungen end 682 9 Theoretische Informatik und Compilerbau Hat die ursprüngliche Satzform mehrere Nonterminale, so darf man in jedem Schritt ein beliebiges davon ersetzen – welches spielt keine Rolle. In den obigen Beispielen haben wir immer das linkeste Nonterminal ersetzt. Eine solche Ableitung heißt Linksableitung – analog gibt es auch den Begriff der Rechtsableitung. Gibt es eine Folge α ⇒ β ⇒ … ⇒ γ von Ableitungen, so schreibt man α ⇒ * γ . Definition: Sei G eine kontextfreie Grammatik mit Startsymbol S. Als Sprache von G bezeichnet man L ( G ) = { w ∈ T* | S ⇒ * w } . L(WHILE) enthält somit u.a. „begin end“ und „begin id := num ; end“. Die vorher erwähnte Sprache KK der korrekten Klammerausdrücke ist L ( G ) für folgende Grammatik: A ::= ( A ) | A A | ε 9.3.3 Stackautomaten (Kellerautomaten) Ähnlich wie im Falle der regulären Sprachen wollen wir zu einer beliebigen Grammatik einen Automaten finden, der die zugehörige Sprache erkennt. Allerdings ist dies mit dem Konzept des endlichen Automaten unmöglich, da endliche Automaten nur reguläre Sprachen erkennen können. Speziell für die Sprache der korrekten Klammerausdrücke haben wir gesehen, dass man mit endlich vielen Zuständen nicht auskommen kann. Wir müssen daher einen neuen Automatenbegriff einführen, den sogenannten Stackautomaten, auf Deutsch auch als Kellerautomat bezeichnet. (Im Süddeutschen wird Stack oft mit Keller übersetzt. Vermutlich beruht dies auf der Erkenntnis, dass die zuletzt eingekellerten Kartoffeln zuerst auf den Tisch kommen.) Zusätzlich zu den endlich vielen Zuständen besitzt dieser noch einen unbegrenzten Stack, auf dem er Symbole eines speziellen Alphabets Γ ablegen kann. Offiziell besteht ein Stackautomat also aus • • • einem endlichen Alphabet Σ , dem sog. Eingabealphabet einem endlichen Alphabet Γ , dem sog. Stackalphabet einer nichtdeterministischen Übergangsfunktion δ : Q × ( Σ ∪ { ε } ) × Γ → ℘ ( Q × Γ* ) Die Absicht ist, dass ein Stackautomat ein Wort w ∈ Σ* akzeptieren soll, wobei er jeweils das erste Zeichen a des Inputs sieht und folgendermaßen arbeitet: Ist der Automat im Zustand q ∈ Q und ist das oberste Stackelement A ∈ Γ , so kann er ein beliebiges ( q', α ) ∈ δ ( q, ε, A ) wählen und in den neuen Zustand q' wechseln, das oberste Stackelement durch das Wort α ∈ Γ* ersetzen. Ist das Eingabezeichen a , so kann er stattdessen auch ein ( q', α ) ∈ δ ( q, a, A ) wählen, das Zeichen a einlesen, in den neuen Zustand q' wechseln, das oberste Stackelement durch das Wort α ∈ Γ* ersetzen. 9.3 Kontextfreie Sprachen 683 Bei der Ersetzung des obersten Stackelementes durch die Satzform α ∈ Γ* werden die Zeichen von α in umgekehrter Reihenfolge auf dem Stack abgelegt, so dass also das erste Element von α zuoberst liegt. Für die Erkennung eines Wortes w ∈ Γ* startet der Automat mit einem speziellen Startsymbol S ∈ Γ auf dem ansonsten leeren Stack und dem Wort w im Input. Es gilt als akzeptiert, wenn es von der Eingabe komplett eingelesen werden kann, so dass anschließend der Stack leer ist. Der Stackautomat arbeitet also nichtdeterministisch, da er bei Eingabesymbol a ∈ Σ und mit A ∈ Γ zuoberst auf dem Stack ein beliebiges ( q', α ) aus δ ( q, ε, A ) ∪ δ ( q, a, A ) wählen kann. Mit L ( A ) bezeichnet man die Sprache des Automaten, d.h. die Menge aller Wörter, die der Stackautomat A akzeptieren kann. Man kann zeigen, dass die Zustandskomponente Q überflüssig ist, da man immer mit einem einzigen Zustand auskommen kann. Wenn man will, kann man daher Q weglassen und δ als Funktion δ : ( Σ ∪ { ε } ) × Γ → ℘ ( Γ* ) angeben. Beispiel: Wähle Σ = { ( , ) } und Γ = { (, ), S } . Setze δ ( ε, S ) = { ε, ( S ), SS } und δ ( (, ( ) = δ ( ), ) ) = { ε } . Ansonsten sei δ ( x, Y ) = ∅ . Offensichtlich kann man diesen Automaten folgendermassen beschreiben: • • Liegt ein Terminalzeichen, im aktuellen Beispiel kann es sich nur um „(“ oder „)“ handeln, auf dem Stack und findet sich das gleiche Zeichen im Input, so wird es eingelesen (akzeptiert). Das oberste Element des Stacks wird dabei durch ε ersetzt, d.h. entfernt. Liegt ein Nonterminalzeichen (im Beispiel S) auf dem Stack, so wird es durch die rechte Seite einer seiner Produktionen gemäß der folgenden Grammatik ersetzt: S :: S S | ( S ) | ε Die folgende Figur zeigt die Stackmaschine mitten in der Erkennung eines Inputwortes. Da das oberste Element des Stacks ein Token ist und mit dem aktuellen Token im Input übereinstimmt, wird es vom Stack entfernt, denn δ ( ), ) ) = { ε } , und der Input rückt eins weiter. Jetzt liegt das Nonterminal S zuoberst auf dem Stack. Es kann durch eine beliebige rechte Seite einer Regel expandiert werden, wobei kein Input verbraucht wird, dies besagt gerade δ ( ε, S ) = { ε, ( S ), SS } . Wählen wir die zweite Regel, so besteht der Stack anschließend aus ( S ) ), was dem restlichen Input entspricht, wenn wir später S mit der dritten Regel S → ε reduzieren. Zu jedem Zeitpunkt gilt, dass die Satzform auf dem Stack gerade dem noch abzuarbeitenden Rest des Inputwortes entspricht. Grammatik S → S S | ( S ) | ε Abb. 9.8: Invariante: Satzform auf Stack ⇒* Rest des Inputs Stack S ) S ) ( Stackautomatbeim Parsen eines Klammerausdrucks ( ) ( ) ) Input 684 9 Theoretische Informatik und Compilerbau 9.3.4 Stackautomaten für beliebige kontextfreie Sprachen Das gerade betrachtete Beispiel verrät schon das Rezept, nach dem zu einer beliebigen kontextfreien Grammatik ein Stackautomat konstruiert werden kann. Sei dazu G eine Grammatik mit Terminalsymbolen T und Nonterminalen NT , dann wählen wir Σ = T und Γ = T ∪ NT . Für jedes Terminalsymbol t ∈ T setzen wir δ ( t, t ) = { ε } und für jede Regel A :: α 1 α 2 … α n setzen wir δ ( ε, A ) = { α 1, α 2, …, α n } . Startet man den Automaten mit dem Startsymbol S auf dem ansonsten leeren Stack, und Wort w ∈ Σ* im Input, so kann er offensichtlich jede Linksableitung von w nachvollziehen. Betrachten wir nämlich eine solche Linksableitung S ⇒ w 1 A 1 β 1 ⇒ … ⇒ w 1 …w k A k β k ⇒ w 1 …w k α k β k ⇒ … ⇒ w wobei jeweils die A i die linkesten Nonterminale sein sollen, dann müssen natürlich w 1 , w 1 w 2 , … , w 1 …w k Anfangsstücke von w sein. Sie können daher durch die Transitionen δ ( t, t ) = { ε } sukzessive entfernt werden, bis wieder ein Nonterminal A i zuoberst auf dem Stack liegt. Ist dann A i → α i die für die Linksableitung benutzte Produktion, so ist auch α i ∈ δ ( ε, A i ) , weshalb A i durch α i ersetzt werden kann. Wir erhalten also: Satz: Zu jeder kontextfreien Grammatik G gibt es einen nichtdeterministischen Stackautomaten A mit L ( A ) = L ( G ) . do Anweisung end Stack lookahead Noch nicht gelesener Input ; eof end num + id := id do num <= id while num := id begin Satzform auf dem Stack entspricht dem noch erwarteten Input d ände Zust ergehen b ü r iebQ r vo t e rB auße Bereits gelesen Abb. 9.9: 9.3.5 Stackautomat beim nichtdeterministischen Top-Down Parsen Nichtdeterministische Algorithmen und Backtracking Eine konkrete Implementierung eines nichtdeterministischen Algorithmus wie des obigen Stackautomaten erfordert meist einen Backtracking Algorithmus. Unter Backtracking versteht man die Möglichkeit, eine von mehreren Alternativen spekulativ auszuwählen. Falls die Auswahl sich später als falsch herausstellt, kann zu dem letzten Entscheidungspunkt zurückge-