Programmiersprachen und Übersetzer Sommersemester 2011 3. Mai 2011 Aufbau von lexikalen Scannern Beispiel Festlegung der Tokenklassen für eine Programmiersprache“, die ” nur einfache Wertzuweisungen mit arithmetischen Ausdrücken und if-Anweisungen enthält. Zunächst definiert man sich zweckmäßigerweise Abkürzungen <letter> = [A - Z] | [a - z] <digit> = [0 - 9] Die Tokenklassen ident (Identifier) und number (vorzeichenlose Integer- und Real-Zahlen) wären dann: <letter> ( <letter> | <digit> )∗ <digit>+ ( .<digit>+ )? Die Tokenklasse relop (Vergleichsoperator) ist durch den regulären Ausdruck: = | <= | <> | < | > | >= definiert und entsprechend arithop durch: + | - | * | / Die reservierten Wörter if, then und else bilden jeweils für sich eine Tokenklasse. Verwendet man einen Scanner-Generator wie etwa LEX, FLEX oder JFLEX, so reicht es aus, reguläre Ausdrücke zu definieren und geeignete Aktionen zu konstruieren, die beim Erkennen eines Tokens ausgeführt werden sollen. Es ist dabei wichtig, dass alle Eingabezeichen zu Token zusammengefasst werden. Aus diesem Grund sollte man dann noch eine weitere Tokenklasse ws (white space) schaffen, die das Überlesen von Leerzeichen usw. steuert. <delim> = <blank> | <tab> | <newline> und die Tokenklasse ws wird definiert durch <delim>+ Die zugeordnete Aktion wäre dann natürlich eine Null-Operation! Arbeitsweise von Scanner-Generatoren (LEX) Lex-Quellenprogramm- Lex-Compiler lex.l lex.yy.c - C-Compiler EingabeProgramm - a.out - lex.yy.c - a.out - Folge von Token Aufbau eines Lex-Programms %{ I Deklarationen von Konstanten, Variablen usw. %} I Deklaration von Abkürzungen für reguläre Teilausdrücke %% I reguläre Ausdrücke mit zugeordneten Aktionen (Übersetzungsregeln) %% I Hilfsfunktionen Aufbau der Übersetzungsregeln Die Übersetzungsregeln haben die Form: reg. Ausdrucki { Aktioni } Aktionen werden in LEX als Codefragmente in C geschrieben. Arbeitsweise des durch LEX generierten Scanners Von der momentanen Position aus liest das Unterprogramm yylex Zeichen für Zeichen der Eingabe, bis es das längste Wort gefunden hat, das zu einem der regulären Ausdrücke passt Dann wird die zugeordnete Aktion ausgeführt, die üblicherweise ein Rücksprung zum Parser mit der Rückgabe der Tokenklasse ist. Bemerkung Gibt es mehr als einen passenden regulären Ausdruck zu diesem längsten Anfangsstück, wird der in der Reihenfolge des Lex-Programms erste reguläre Ausdruck gewählt. Kommunikation mit dem Parser Die Übergabe der Information von yylex an den Parser geschieht meist wie folgt: - der Rückgabewert von yylex gibt die Tokenklasse an, - der Tokenwert wird in einer globalen Variable yylval (ist vom Parser zu liefern!) gespeichert, - yytext ist ein Zeiger auf das erste Zeichen des Tokens und - yyleng ist die Länge des Tokens in der Eingabe. Damit können die Hilfsfunktionen des Scanners oder aber der Parser auf das gefundene Token zugreifen und eventuell weitere Aktionen ausführen. Beispieleingabe für LEX /**************************************************************/ /* scanner.l */ /**************************************************************/ /* Definition von Konstanten, Variablen und Typen */ %{ /* Hier werden die Tokenklassen numerisch codiert */ #define IDENT 1 #define NUMBER 2 #define RELOP 3 #define ARITHOP 4 #define ASSIGNOP 5 #define IF 6 #define THEN 7 #define ELSE 8 #define SEMIK 9 /* Weitere Definitionen z.B. fuer einzelne Operationen */ /* Dies sind die #define ADD #define SUB #define MULT #define DIV Tokenwerte zur Tokenklasse 4 */ 41 42 43 44 /* Dies sind die Tokenwerte zur Tokenklasse 3 */ #define GT 31 #define GE 32 #define LT 33 #define LE 34 #define EQ 35 #define NE 36 %} /* Definition regulaerer Ausdruecke */ /* LEX-Konvention: bei der Definition eines Ausdrucks wird name, */ /* beim Gebrauch {name} verwendet. */ delim ws letter digit ident number [ \t\n] /* \t fuer Tabulator, \n für Zeilenende */ {delim}+ [A-Za-z] [0-9] {letter}({letter}|{digit})* {digit}+(\.{digit}+)? /* . steht in LEX für ein beliebiges Zeichen, daher %% \.*/ {ws} { /* keine Aktion und kein Ruecksprung */ } "if" "then" "else" /* zuerst kommen die Schluesselwoerter */ { return(IF); } { return(THEN); } { return(ELSE); } {ident} {number} { yylval = install_id(); return(IDENT); } { yylval = install_num(); return(NUMBER); } "*" "/" "+" "-" "<=" "<" "=" ">" ">=" "<>" ":=" ";" %% { { { { { { { { { { { { yylval = MULT; return(ARITHOP); } yylval = DIV; return(ARITHOP); } yylval = ADD; return(ARITHOP); } yylval = SUB; return(ARITHOP); } yylval = LE; return(RELOP); } yylval = LT; return(RELOP); } yylval = EQ; return(RELOP); } yylval = GT; return(RELOP); } yylval = GE; return(RELOP); } yylval = NE; return(RELOP); } return(ASSIGNOP); } return(SEMIK); } /* Hier wuerden Hilfsfunktionen wie install_id() usw. stehen */ /* Will man vielleicht nur eine Liste der Token ausgeben, so koennte man an dieser Stelle auch ein Hauptprogramm definieren, z.B.: */ main() { int c; while (c = yylex()) /* Rueckgabewert von yylex */ /* ist 0 falls eof gelesen */ printf( "TOKENKLASSE: %d %s\n", c, yytext); } Hat man keinen Generator für einen Scanner zur Verfügung oder ist die Verwendung eines automatisch erzeugten Scanner zu aufwendig, stellt sich die Frage, wie man einen Scanner für eine gegebene Menge regulärer Ausdrücke konstruieren kann. Dazu 3 Schritte: 1. Transformation regulärer Ausdrücke in Transitionsgraphen (Darstellung eines nicht-deterministischen Automaten) 2. Transformation des nicht-deterministischen in einen deterministischen Automaten 3. Implementation des Automaten Transitionsgraphen Transitionsgraphen sind eine graphische Darstellung der Arbeitsweise endlicher Automaten, die eine Eingabe Zeichen für Zeichen lesen und dabei verschiedene Zustände durchlaufen. I Zustände werden durch Knoten und Überführungen durch Kanten dargestellt. I Es gibt es einen Startzustand, in dem der Automat startet, und mindestens einen Endzustand. I Gelangt der Automat durch Lesen der Zeichen der Eingabe vom Startzustand aus in einen Endzustand, so hat er das gelesene Wort in der Eingabe akzeptiert. Satz Die Menge aller so akzeptierten Wörter bildet eine reguläre Sprache Beispiel Ein Übergang der Form i c j hat folgende Bedeutung: Befindet sich der Automat im Zustand i und liest er als nächstes Zeichen ein c, so geht er in den Zustand j über (Folgezustand). Ein doppelter Kreis markiert einen Endzustand. Prinzipielle Arbeitsweise eines endlichen Automaten Eingabeband Lesekopf - Steuerwerk mit Zuständen Akzeptieren von Eingabewörtern bei Scannern Für einen Scanner muss das Akzeptanzverhalten ein wenig geändert werden, da die Eingabe aus einer Folge von Lexemen besteht. Von der momentanen Position aus werden Eingabezeichen gelesen, bis man in einen Zustand z gerät, in dem keine Überführung mehr durchgeführt werden kann. I Ist z ein Endzustand, dann gehört das gelesene Wort zu einer Tokenklasse, die durch den Endzustand bestimmt ist. I Ist z kein Endzustand und wurde während des Lesens mindestens ein Endzustand erreicht, so bestimmt der letzte dieser erreichten Endzustände die Tokenklasse. Die gefundene Zeichenkette besteht also aus allen bis hierher gelesenen Zeichen und der Lesekopf muss auf das nachfolgende Zeichen zurückgesetzt werden. I Ist z kein Endzustand und wurde während des Lesens zwischendurch kein Endzustand erreicht, liegt offensichtlich ein Fehler vor. Beispiel Der Automat erkennt die Wörter, die durch den regulären Ausdruck [0-9]+ (.[0-9]+ )? bezeichnet werden 0-9 start 0 0-9 1 0-9 . 2 0-9 3 Beispiel Die Tokenklasse T1 sei durch ab, die Tokenklasse T2 sei durch abc+ d und die Tokenklasse T3 sei durch c+ b definiert. Ein zugehöriger Transitionsgraph könnte folgendes Aussehen haben: b a start 1 c 2 d 3 c 0 c c 5 b 6 In der Eingabe befinde sich das Wort abcccb, 4 Definition Ein Transitionsgraph (TG) heißt deterministisch, falls er 1. keine Übergänge besitzt, die mit dem leeren Wort ε markiert sind und 2. keine Zustände besitzt, von denen aus es mehr als einen mit einem Zeichen a des Alphabets markierten Übergang gibt. Verletzt ein Transitionsgraph eine der beiden Bedingungen, wird er nichtdeterministisch genannt. Beispiel a start 0 a 1 b 2 b 3 b Die Menge der von diesem Automaten erkannten Worte wird durch den regulären Ausdruck (a|b)∗ abb bezeichnet. Beispiel b 1 a 2 ε start 0 a ε 3 b 4 Die Menge der von diesem Automaten erkannten Worte wird durch den regulären Ausdruck ab∗ |ba∗ bezeichnet. Erzeugung von Scannern aus deterministischen Transitionsgraphen Tabellengesteuerter Scanner Transitionstabelle T für Transitionsgraphen aus dem vorangehenden Beispiel Zustand 0 1 2 3 Eingabezeichen 0-9 . 1 – 1 2 3 – 3 – Aufbau eines tabellengesteuerten Scanners Eingabe $ H Y H H Ende-Markierung Lesefenster Steuerwerk (mit Zuständen) ? erkannte Token - Transitionstabelle T Arbeitsweise des tabellengesteuerten lexical Scanners I I Initiale Situation: Auf dem Eingabeband steht das zu lesende Wort gefolgt von einer Endmarkierung. Das Lesefenster steht auf dem ersten Buchstaben, und die Steuerung ist im Startzustand. Arbeitsschritt: Abhängig vom momentanen Zustand z der Steuerung und dem Zeichen c unter dem Lesefenster wird in der Transitionstabelle unter dem Eintrag T (z, c) nachgesehen. a) T (z, c) enthält einen Zustand z 0 . Dann geht die Steuerung in den Zustand z 0 über und das Lesefenster wird um ein Zeichen nach rechts geschoben. Ist z 0 ein Endzustand, so merkt man sich den Zustand und die Position des Lesefensters. Arbeitsweise des tabellengesteuerten lexical Scanners b) T (z, c) enthält keinen Zustand und man hat sich unter a) einen Endzustand gemerkt. Dann bestimmt der letzte gemerkte Endzustand, welches Token erkannt wurde und der Lesezeiger wird auf die entsprechende Position gesetzt. c) T (z, c) enthält keinen Zustand und man hat sich unter a) keinen Endzustand gemerkt. Dann ist ein Fehler in der Eingabe aufgetreten. Bemerkung Um das nächste Token in der Eingabe zu erkennen, wird das Steuerwerk auf den Startzustand gesetzt und der Automat erneut gestartet. Direkt erzeugter Scanner für die Tokenklasse int scanner () { int zustand; char zeichen; zustand = 0; while (TRUE) { if (feof(stdin)) return (EOF); zeichen = getchar(); switch (zustand) { case 0: if (isdigit(zeichen)) zustand = 1; else error(); break; case 1: if (zeichen == ’.’) zustand = 2; else if (!isdigit(zeichen)) { ungetc(zeichen); return (NUMBER); } break; case 2: if (isdigit(zeichen)) zustand = 3; else error(); break; case 3: if (!isdigit(zeichen)) { ungetc(zeichen); return(NUMBER); } break; } } } Erzeugung von Transitionsgraphen aus regulären Ausdrücken 1. Ist der reguläre Ausdruck von der Form ε, so ist der zugehörige TG von der Form: start ε 2. Ist der reguläre Ausdruck von der Form a, so ist der zugehörige TG von der Form: start a 3. Ist der reguläre Ausdruck von der Form α1 | α2 , so ist der zugehörige TG von der Form: ε TG für α 1 ε start ε TG für α 2 ε 4. Ist der reguläre Ausdruck von der Form α1 α2 , so ist der zugehörige TG von der Form: start TG für α 1 ε TG für α 2 5. Ist der reguläre Ausdruck von der Form α∗ oder α+ , so ist der zugehörige TG von der Form: ε start ε TG für α ε ε entfällt bei α+ Umwandlung eines nichtdeterministischen Transitionsgraphen T in einen deterministischen 1. Schritt: Entfernen der ε-Übergänge. Idee: Befindet sich der Automat nach Lesen eines Buchstabens im Zustand z, so befindet“ er sich ebenso in jedem Zustand z 0 , der ” von z aus durch eine Folge von ε-Übergängen erreichbar ist. Die Menge der Knoten in dem nichtdeteministischen Transitionsgraphen T sei K . Es wird ein neuer Transitionsgraph T 0 folgendermaßen konstruiert: I T 0 enthält alle Knoten aus K , auf die wenigstens ein Übergangspfeil zeigt, der nicht mit ε markiert ist, sowie den Startknoten von T . Die Menge dieser Knoten werde mit K 0 bezeichnet. I In T 0 wird ein Übergangspfeil vom Knoten k1 zum Knoten k2 mit der Markierung a eingetragen, wenn man in T vom Knoten k1 mit einer Folge von ε-Übergängen zu einem Knoten k gelangen kann, von dem ein mit a markierter Übergangspfeil zum Knoten k2 führt. (Diese Folge der ε-Übergänge darf auch leer sein.) I Der Startknoten in T ist auch Startknoten in T 0 . I Endknoten sind alle Knoten k aus K 0 , von denen aus in T ein Endknoten mit einer (auch leeren) Folge von ε-Übergängen erreicht werden kann. 2. Schritt: Vom nichtdeterministischen TG T ohne ε-Übergänge zum deterministischen TG. Idee: Befindet sich der Automat in einem Zustand z und könnte er nach Lesen eines Zeichens a sowohl in den Zustand z1 als auch in den Zustand z2 gehen, so geht“ er in beide Zustände. ” Gegeben sei ein nichtdeterministischer Transitionsgraph TN ohne ε-Überführungen. Die Menge der Knoten von TN sei mit KN bezeichnet. Konstruiert wird ein deterministischer Transitionsgraph TD , dessen Knoten aus gewissen Teilmengen von Knoten aus KN bestehen. Die Menge der Knoten KD von TD wird dabei sukzessive bestimmt. KD enthält zu Beginn nur die Menge, die den Startknoten von TN enthält. Anschließend wird folgendes solange ausgeführt, bis keine neuen Knoten mehr zu KD hinzugefügt und keine neuen Überführung mehr in TD eingetragen werden: I Ist P ein Knoten aus KD (d.h. P = {z1 , . . . , zr } mit zi ∈ KN ), so wird für jedes Eingabezeichen a die Menge Q aller Knoten in KN bestimmt, für die in TN eine Überführung von einem Knoten aus P in einen Knoten aus Q mit der Markierung a vorhanden ist. I Ist Q 6= ∅ und existiert ein Zustand Q noch nicht in KD , so wird Q zu den bisher schon in KD vorhandenen Knoten hinzugefügt. I Außerdem wird eine mit a markierte Überführung in TD von P nach Q eingetragen. I Der Startknoten in TD ist die Menge, die nur den Startknoten von TN enthält. I Ein Knoten {z1 , . . . , zr } in TD wird Endknoten in TD , wenn einer der Knoten zi ∈ KN mit i ∈ [1 : r ] ein Endknoten in TN ist.