9 Theoretische Informatik und Compilerbau

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