Compilerbau/Kapitel 1: Lexikalische Analyse
Aus Halloserv Wiki
Die lexikalische Analyse bildet den ersten Schritt des Compilers und wandelt den Quellcode eines Programmes in eine Liste von Token-Attribut Paaren um. Dabei werden zum Beispiel Whitespaces ignoriert, Schlüsselwörter erkannt und Formatierungszeichen gefiltert. Die lexikalische Analyse wird von einem Lexer durchgeführt, der aus einem Scanner und einem Tokenizer besteht.
Inhaltsverzeichnis |
Lexeme und Token
Der Quellcode eines Programmes ist aus Lexemen zusammengesetzt, der Scanner erkennt diese und wandelt sie in Token-Attribut Paare um.
new Segment(pi/2)
| Lexem | Token |
|---|---|
| new | <new> |
| Segment | <id [Segment]> |
| ( | <lparen> |
| pi | <id [pi]> |
| / | <slash> |
| 2 | <integer-literal [2]> |
| ) | <rparen> |
Scanner
Sei Σ ein Eingabealphabet, T eine endliche Tokenmenge und A eine beliebige Attribuierung. Ein Scanner ist eine Funktion
, sodass eine Funktion
existiert und gilt:
-
- unscan ist homomorph bezüglich
-
- im Allgemeinen gilt nicht
Konstruktion von Scannern
Es gibt zur Konstruktion von Scannern folgende Möglichkeiten:
- von Hand
- benutze Bibliothek (in der Vorlesung)
- Scannergenerator (Beispiele: lex, flex für C; jlex, jflex für Java; ocamllex für OCaml)
Bei Ansatz b) und c) werden die Lexeme durch eine reguläre Sprache definiert. Dafür gibt es einige Möglickeiten, namentlich die reguläre Grammatik, einen endlicher Automaten (DFA / NFA / ε-NFA) oder regulärer Ausdrücke. Es wird eine Repräsentation der Lexeme benötigt, die eine abstrakte und prägnante Spezifikation sowie eine effiziente Erkennung bereitstellt. Als optimale Lösung ergibt sich, dass Lexeme durch regulärer Ausdrücke spezifiziert werden und die Erkennung mit Hilfe von DFAs implementiert wird. Die folgenden beiden Abschnitte spezifizieren diese Beschreibungen.
DFA
Ein DFA (Deterministic Finite Automaton) ist ein 5-Tupel M = (Q,Σ,δ,q0,F):
Erweiterung:
Damit gilt:
.
reguläre Ausdrücke
Um Literale reguläre Ausdrücke von Symbolen zu unterscheiden, werden sie in diesem Text unterstrichen dargestellt, also
statt (a | ε).
Die Menge der regulären Ausdrücke ist die kleinste Menge RE(Σ) mit
r + = rr *
Vom regulären Ausdruck zum DFA
Wie oben beschrieben, werden Lexeme in regulären Ausdrücken spezifiziert, ihre Erkennung aber mit Hilfe eines DFA durchgeführt. Traditionell erhält man den DFA folgendermaßen:
Hier wählen wir ein anderes Verfahren, wir übersetzen direkt vom regulären Ausdruck in einen DFA (nach Brzozowski, Derivations of Regular Expressions, 1964). Die Idee besteht darin, reguläre Ausdrücke als Zustände des DFA zu nutzen. Da jeder Zustand eines Automaten eine Sprache erkennt (nämlich die Wörter, die von hier aus zu einem Endzustand führen), kann diese Sprache durch einen regulären Ausdruck beschrieben werden, der dann die Beschreibung des Zustands liefert.
- Falls
, dann erkennt der reguläre Ausdruck q die Wörter "ab q", d.h. die Wörter, die q in einen Endzustand überführen (hierbei ist q sowohl Zustandsname als auch regulärer Ausdruck).
- q0 ist der zu übersetzende reguläre Ausdruck
-
falls L(q) = ε
Man muss nun aus dem gegebenen regulären Ausdruck einen Automaten konstruieren, der ihn erkennt. Hierzu definiert man die Ableitung eines regulären Ausdrucks als Funktion
mit
. Bevor wir eine Beschreibung von D geben können, benötigen wir noch die Hilfsfunktion
, die auf ε testet, d.h.
zurückgibt, wenn der reguläre Ausdruck nach
aufgelöst werden kann und
sonst.
Für
gilt
.
Nun können wir an die Konstruktion des Automaten M gehen:
Sei
. Definiere M = (Q,Σ,δ,q0,F) wie folgt:
ist die kleinste Menge, so dass a)
und b)
.
Dann gilt: L(r0) = L(M). Q ist endlich, falls das Ergebnis von D wie folgt vereinfacht wird:
Hier sieht man, wie ein Zustand dd * aus dem Anfangszustand r0 und dem Eingabesymbol − berechnet wird.
Der echte Scanner
Mit Hilfe von regulären Ausdrücken, genauer dem matcher, können wir feststellen, ob ein Wort von einem regulären Ausdruck akzeptiert wird, oder formal, ob gilt:
. Ein Scanner aber muss eine ganze Reihe von Lexemen erkennen und sie in Token-Attribut Paare wandeln. Er beantwortet also die Frage:
und dann
. Genauer heisst das, dass der Scanner für die Eingabe u ein w,v bestimmt, so dass gilt u = wv und
. Leider ist diese Bestimmung nicht eindeutig, da matches überlappen können:
Rule of the Longest Match
formel1 -> Bezeichner for -> Schlüsselwort
Eindeutigkeit erzielt man mit Hilfe der Rule of the longest Match, d.h. der längste passende Abschnitt wird gewählt, in obigem Beispiel also formel1. Sind beide matches gleich lang, wird die als erstes kommende Spezifikation bevorzugt.
Implementation
Im Folgenden wird der Code eines in OCaml geschriebenen echten Scanners vorgestellt, der auf den vorausgegangenen Überlegungen basiert.
Der Typ lex-action beschreibt Aktionen um Lexeme in Token-Attribut Paare zu wandeln.
type ('a, 'token, 'attrib) lex_action =
'a list * 'a list -> 'token * 'attrib * 'a list
a) b) c)
</div> Dabei stehen die Listen für diese Symbole:
- erkanntes Lexem
- Rest der Eingabe
- Rest der Eingabe, ist meistens identisch mit b) (d.h. wird durchgeschleift, es gibt aber Ausnahmen: z.B. werden Kommentare entfernt)
Eine Regel der Scannerspezifikation, lex_rule kombiniert einen regulären Ausdruck (zum Erkennen des Lexems) mit einer Aktion (zur Umwandlung in Token-Attribut Paare).
type ('a, 'token, 'attrib) lex_rule =
'a Regexp.regexp * ('a, 'token, 'attrib) lex_action
</div>
Während des Durchlaufens des Inputstrings, merkt sich der Scanner den aktuellen Zustand in einer Struktur lex_state:
type ('a, 'token, 'attrib) lex_state =
(('a, 'token, 'attrib) lex_rule) list
</div> Eine Scannerspezifikation ist dann nichts weiter als ein solcher Zustand, nämlich eine Liste von lex_rules.
let initial_state spec = spec
</div>
Mit next_state bearbeitet der Scanner ein Symbol und entfernt alle inaktiven Regeln.
let next_state state symbol =
let after_state =
List.map
(function (regexp, action) -> Regexp.after_symbol symbol regexp, action)
state
in
filter
(function (regexp, _) -> not (Regexp.is_null regexp))
after_state
</div>
Die Methode matched_rules schliesslich ermittelt die Endzustände (jene Regeln, die gerade ein Lexem erkannt haben).
let matched_rules state =
filter
(function (regexp, _) -> Regexp.accepts_empty regexp)
state
</div>
Sind alle Regeln inaktiv, landen wir bei is_stuck:
let is_stuck state = state = []
</div>
Nun folgt der Scanner Code selbst, scan_one extrahiert das nächste Lexem am Anfang des Inputs und wandelt es mit Hilfe der entsprechenden Aktion in ein Token-Attribut Paar, welches zusammen mit dem Rest des Inputs zurück gegeben wird.
let scan_one spec input =
let (action, rev_lexeme, rest) =
scan_loop (initial_state spec) [] None input
in
action (List.rev rev_lexeme, rest)
let rec scan_loop state rev_lexeme last_match input =
if (is_stuck state) or (input = [])
then
match last_match with
None -> raise Scan_error
| Some match -> match
else
let symbol::rest = input in
let new_state = next_state state symbol in
let new_rev_lexeme = symbol::rev_lexeme in
let new_match =
match (matched_rules new_state) with
[] -> last_match
| (regexp, action)::_ -> Some (action, new_rev_lexeme, rest)
in
scan_loop new_state new_rev_lexeme new_match rest
Typischerweise benötigt eine Sprache mehrere Lexerspezifikationen (zum Beispiel Normalzustand (Bezeichner, Literale, Schlüsselwörter), Stringliterale (inneres) und reguläre Ausdrücke (z.B. in JavaScript)).
let rec scan_normal input = scan_one <spec_normal> input
and scan_string input = scan_one <spec_string> input
and scan_regexp input = scan_one <spec_regexp> input
Zusammen mit der Methode scan_one, welche ein einzelnes Lexem erkennt, kann man mit diesen Spezifikationen nun den kompletten Scanner zusammensetzen:
let make_scanner scan_one input =
let rec loop result rest =
if rest = []
then List.rev result
else
let (token, attrib, new_rest) = scan_one rest in
loop ((token, attrib)::rev_result) rest
in
scan [] input
</div>
Pragmatik
Bisher haben wir die Erkennung von Schlüsselwörtern im Scanner durch triviale reguläre Ausdrücke erledigt. Das allerdings führt zu einer großen Zustandsmenge und ist ineffizient. Alternativ kann man aber auch eine effizientere Methode verwenden: Man scannt zunächst alle Schlüsselwörter als Bezeichner und konstruiere vorab eine Hashtabelle mit allen Schlüsselwörtern. Nachträglich klassifizert man dann Bezeichner als Schlüsselwörter. Benutzt man perfektes Hashing, kann diese Klassifizierung in O(1) vorgenommen werden.
Der Compiler verwaltet eine Symboltabelle, die Bezeichner auf zugehörige Informationen (Art, Typ, ...) abbildet, das heisst der Bezeichner dient als Schlüssel. Strings sind als Schlüssel allerdings nicht optimal, weswegen Bezeichner beim Scannen auf eindeutige Zahlen abgebildet werden.
.

