Compilerbau/Aufarbeitung Komplett

Aus Halloserv Wiki

Wechseln zu: Navigation, Suche

Inhaltsverzeichnis

Einführung

Einführung auf einer Seite lesen

Inhalt

Ein Compiler ist ein Übersetzer von einer Quellsprache in eine Zielsprache. Die Quellsprache ist meistens eine höhere Programmiersprache (Java, C, Ocaml), die Zielsprache eine maschinennahe Programmiersprache (Assembler, ObjectCode, Maschienencode). Es gibt verschiedene Arten von Compilern: Batch-Compiler, integrierte Compiler (z.B. in Eclipse) und just-in-time Compiler (auf Bytecode-Ebene). Wir werden im Folgenden einen Batch-Compiler mit klassischer Pipeline-Architektur behandeln.

- Quellsprache \to Frontend -Zwischensprache \to Backend -Zielsprache \to [IMAGE]

Warum benutzt man eine Zwischensprache? Schreibt man einen Compiler für m Quellsprachen und n Zielsprachen, dann hat der minimale Compiler mit Zwischensprache m + n Programme. Frontend und Backend sind jeweils für eine Reihe von Funktionen zuständig:

Frontend

Backend

  • Transformation/Optimierung

von hier an plattformabhängig:

  • Generierung von Assemblercode (abstrakt) / Auswahl der Instruktionen
  • Scheduling
  • Datenflussanalyse
  • Registerallokation
  • Codeausgabe

Im Folgenden werden die einzelnen Funktionen näher erläutert:

lexikalische Analyse

Die lexikalische Analyse enthält Lexer und Scanner und verwendet endlicher Automaten. Ihre Aufgabe ist die Aufteilung des Quellprogramms in Sinneinheiten (Symbole).

new Segment ( pi / 2 )

leads to:

Keyword(new)
Identifier("Segment")
Lparen
Identifier("pi")
Slash
Integer(2)
Rparen

syntaktische Analyse

Die syntaktische Analyse enthält den Parser, sie ermittelt die Struktur des Quellprogramms. Die syntaktische Struktur wird durch kontextfreie Grammatiken spezifiziert.

<expr> ::= Keyword("new") <ident> Lparen <param-list> rparen
	| <expr> Slash <expr>
	| <ident>
	| <integer-literal>
<param-list> ::= <expr> <rest-list>
<rest-list> ::= Comma <expr> <rest-list>

Ergebnis: konstruiert Syntaxbaum (Ableitungsbaum) zu Liste von Symbolen, abtstrakter Syntaxbaum (AST). [IMAGE]

semantische Analyse

Die semantische Analyse behandelt folgende Fragen:

  • Welche Definition gehört zu einem Vorkommen eines Bezeichner ?
  • Welchen Typ hat ein Ausdruck?
  • Wird eine Variable vor ihrer Initialisierung verwendet? (auch: Datenflussanalyse)
  • Welche Exceptions werden von einem Methodenrumpf geworfen? (Pattern: Baumdurchlauf mit annotierten Knoten)


Datenflussanalyse

Eine Datenflussanalyse ist notwendig, da manche Optimierungen nur dann korrekt sind, wenn ein Programmen gewisse Eigenschaften erfüllt. Ein Beispiel hierfür ist die Lebendigkeitsanalyse.


Registerallokation

Bei der Registerallokation werden Zwischenergebnissen Maschinenregister zugeordnet. Bildet man diese Zuordnung auf ein Problem aus der Graphentheorie ab, kann man sie mittels einer Heuristik lösen.


Übersetzung in Zwischensprache

Dieses Kapitel ist auch als m/n Problem bekannt.

Lexikalische Analyse

Kapitel auf einer Seite lesen

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.


Lexeme und Token

Der Quellcode eines Programmes ist aus Lexemen zusammengesetzt, der Scanner erkennt diese und wandelt sie in Token-Attribut Paare um.

Definition: Lexeme, Token

Ein Lexem ist ein Sinnabschnitt. Ein Token ist ein symbolischer Name für Gruppen von Lexemen (Gruppierung). Die meisten Token tragen Attribute, welche aus Lexemen berechnet wurden.

Bemerkung: Isomorphie

Lexeme und Token-Attribut Paare sind isomorph.

Beispiel: Lexeme und Token
new Segment(pi/2)
Lexem Token
new <new>
Segment <id [Segment]>
( <lparen>
pi <id [pi]>
/ <slash>
2 <integer-literal [2]>
) <rparen>

Scanner

Definition: Scanner

Sei Σ ein Eingabealphabet, T eine endliche Tokenmenge und A eine beliebige Attribuierung. Ein Scanner ist eine Funktion scan: \Sigma^* \to (T \times A)^*, sodass eine Funktion unscan: (T \times A)^* \to \Sigma^* existiert und gilt:

  1. scan \circ unscan = id_{(T \times A)^*}
  2. unscan ist homomorph bezüglich untoken: (T \times A) \to \Sigma^*, unscan(t_1 t_2 \ldots) = untoken(t_1)untoken(t_2)\ldots
Bemerkung:
  1. scan \circ unscan \circ scan = scan
  2. im Allgemeinen gilt nicht unscan \circ scan = id_{\Sigma^*}

Konstruktion von Scannern

Es gibt zur Konstruktion von Scannern folgende Möglichkeiten:

  1. von Hand
  2. benutze Bibliothek (in der Vorlesung)
  3. 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

Definition: DFA

Ein DFA (Deterministic Finite Automaton) ist ein 5-Tupel M = (Q,Σ,δ,q0,F):

\begin{array}{ll}
Q & = \mbox{endliche Menge der Zustände} \\
\Sigma & = \mbox{Eingabealphabet} \\
\delta & = Q \times \Sigma \to Q \mbox{ Übergangsrelation} \\
q_0 & = \mbox{Anfangszustand} \\
F & = \mbox{Menge der Endzustände}
\end{array}

Erweiterung: \delta^* = Q \times \Sigma^* \to Q

\begin{array}{ll}
\delta^*(q, \epsilon) & = q \\
\delta^*(q, aw) & = \delta^*(\delta(q,a), w)
\end{array}

Damit gilt: w \in L(M) \Leftrightarrow \delta^*(q_0, w) \in F.

reguläre Ausdrücke

Um Literale reguläre Ausdrücke von Symbolen zu unterscheiden, werden sie in diesem Text unterstrichen dargestellt, also (\underline{a}|\underline{\epsilon}) statt (a | ε).

Die Menge der regulären Ausdrücke ist die kleinste Menge RE(Σ) mit

\begin{array}{lcll}
\underline \emptyset \in RE(\Sigma) & & & \mbox{Null-Ausdruck} \\
\underline \epsilon \in RE(\Sigma) & & & \mbox{leeres Wort} \\
a \in \Sigma & \Rightarrow & \underline{a} \in RE(\Sigma) & \\	
r_1, r_2 \in RE(\Sigma) & \Rightarrow &	r_1r_2 \in RE(\Sigma) &	\mbox{Konkatenation} \\
 & & r_1 | r_2 \in RE(\Sigma) & \mbox{Alternative} \\
 & & r_1^* \in RE(\Sigma) & \mbox{Wiederholung}
\end{array}

Definition: Sprache

Funktion L: RE(\Sigma) \to \mathcal{P}(\Sigma^*)

\begin{array}{ll}
L(\underline{\emptyset}) & = \emptyset \\
L(\underline{\epsilon}) & = {\epsilon} \\
L(\underline{a}) & = \lbrace a \rbrace \\
L(r_1r_2) & = L(r_1)L(r_2) = \lbrace w_1w_2 | w_1 \in L(r_1), w_2 \in L(r_2) \rbrace \\
L(r_1 | r_2) & = L(r_1) \cup L(r_2) \\
L(r^*) & = L(r)^* = \lbrace w_1w_2...w_n | n \in \mathbb{N}, w_i \in L(r) \rbrace = \lbrace \epsilon \rbrace \cup L(r) \cup L(r)L(r) \cup \ldots
\end{array}

Ein Wort w gehört zur von r beschriebenen Sprache genau dann, wenn w \in L(r).

Bemerkung: r +

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:

\mbox{regulärer Ausdruck} \to \mbox{NFA}-\epsilon \to \mbox{NFA} \xrightarrow{\mbox{Potenzmengen-Konstruktion}} \mbox{DFA}

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.

Beispiel:

r_0 = (\underline{\epsilon} | \underline{-})\underline{d}\underline{d}^*

Image:1-Example-DFA-Construction.png

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 D: RE(\Sigma) \times \Sigma \to RE(\Sigma) mit aw \in L(r) \Leftrightarrow w \in L(D(r,a)). Bevor wir eine Beschreibung von D geben können, benötigen wir noch die Hilfsfunktion E: RE(\Sigma) \to RE(\Sigma), die auf ε testet, d.h. \underline{\epsilon} zurückgibt, wenn der reguläre Ausdruck nach \underline{\epsilon} aufgelöst werden kann und \underline{\emptyset} sonst.

Definition: E

\begin{array}{ll}
E(\underline{\emptyset}) & = \underline{\emptyset} \\
E(\underline{\epsilon}) & = \underline{\epsilon} \\
E(\underline{a}) & = \underline{\emptyset} \\
E(r_1r_2) & = E(r_1)E(r_2) \\
E(r_1 | r_2) & = E(r_1) | E(r_2) \\
E(r^*) & = \underline{\epsilon}
\end{array}

Lemma:

Für r \in RE(\Sigma) gilt L(E(r)) = L(r) \cap \lbrace \epsilon \rbrace.

Definition: D

\begin{array}{ll}
D(\underline{\emptyset}, a) & = \underline{\emptyset} \\
D(\underline{\epsilon}, a) & = \underline{\emptyset} \\
D(\underline{a},b) & =\begin{cases} \underline{\epsilon}, & \mbox{falls } a = b \\ \underline{\emptyset} & \mbox{sonst} \end{cases} \\
D(r_1r_2, a) & = D(r_1, a)r_2 | E(r_1)D(r_2,a) \\
D(r_1 | r_2, a) & = D(r_1, a) | D(r_2, a) \\
D(r^*, a) & = D(r,a)r^*
\end{array}

Nun können wir an die Konstruktion des Automaten M gehen:

Theorem:

Sei r_0 \in RE(\Sigma). Definiere M = (Q,Σ,δ,q0,F) wie folgt:

Q \subseteq RE(\Sigma) ist die kleinste Menge, so dass a) r_0 \in Q und b) r \in Q, a \in \Sigma \Rightarrow D(r,a) \in Q.

\begin{array}{ll}
\delta(q, a) & = D(q, a) \\
q_0 & = r_0 \\
F & = {r \in Q | \epsilon \in L(r)}
\end{array}

Dann gilt: L(r0) = L(M). Q ist endlich, falls das Ergebnis von D wie folgt vereinfacht wird:

\begin{array}{lll}
r\underline{\emptyset} & = \underline{\emptyset} & \\
r\underline{\epsilon} & = \underline{\epsilon}r & = r \\
r | \underline{\emptyset} & = \underline{\emptyset} | r & = r \\
\underline{\emptyset}^* & = \underline{\epsilon}^* & = \underline{\epsilon} \\
(r^*)^* & = r* &
\end{array}

Beispiel: Zustandsberechnung

Hier sieht man, wie ein Zustand dd * aus dem Anfangszustand r0 und dem Eingabesymbol berechnet wird.

\begin{array}{ll}
r_0 & = (\underline{\epsilon}|\underline{-})dd^* \\
D(r_0, -) & = D(\underline{\epsilon} | \underline{-}, -)dd^* | E(\underline{\epsilon} | -)D(dd^*, -) \\
 & = (D(\underline{-},-) | D(\underline{\epsilon}, -))dd^* | (E(\underline{\epsilon})| E(\underline{-})) D(dd^*, -) \\
 & = (\underline{\epsilon} | \underline{\emptyset}) dd^* | (\underline{\epsilon} | \underline{\emptyset}) D(dd^*, -) \\
 & = dd^* | D(dd^*, -) \\
 & = dd^* | (D(d,-)d^*|E(-)D(d^*,-)) \\
 & = dd^* | \emptyset \\
 & = dd^*
\end{array}

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: w \in RE(r). Ein Scanner aber muss eine ganze Reihe von Lexemen erkennen und sie in Token-Attribut Paare wandeln. Er beantwortet also die Frage: w \in RE(r_1) \cup \ldots \cup RE(r_n) und dann w \in RE(r_i). Genauer heisst das, dass der Scanner für die Eingabe u ein w,v bestimmt, so dass gilt u = wv und w \in RE(r_i). Leider ist diese Bestimmung nicht eindeutig, da matches überlappen können:

Rule of the Longest Match

Beispiel: unklarer 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.

<div style="padding-left:4px;" id="Code: lex-action">Code: lex-action
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:

  1. erkanntes Lexem
  2. Rest der Eingabe
  3. 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).

<div style="padding-left:4px;" id="Code: lex_rule">Code: lex_rule
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:

<div style="padding-left:4px;" id="Code: lex_state">Code: 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.

<div style="padding-left:4px;" id="Code: initial_state">Code: initial_state
let initial_state spec = spec

</div>

Mit next_state bearbeitet der Scanner ein Symbol und entfernt alle inaktiven Regeln.

<div style="padding-left:4px;" id="Code: next_state">Code: next_state
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).

<div style="padding-left:4px;" id="Code: matched_rules">Code: matched_rules
let matched_rules state =
  filter 
    (function (regexp, _) -> Regexp.accepts_empty regexp)
    state

</div>

Sind alle Regeln inaktiv, landen wir bei is_stuck:

<div style="padding-left:4px;" id="Code: is_stuck">Code: 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.

Code: Scanner
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:

<div style="padding-left:4px;" id="Code: make_scanner">Code: make_scanner
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.

Syntaxanalyse

Kapitel auf einer Seite lesen

Nachdem der Lexer den Quellcode in eine Liste von Token-Attribut Paaren aufgespalten hat, geht es nun darum, die syntaktische Struktur des Quellprogrammes zu erkennen. Ziel des zur syntaktischen Analyse eingesetzten Parsers ist es, aus der gegebenen Liste einen Ableitungsbaum (bzw. einen abstrakten Syntaxbaum, AST) zu bauen, der die Struktur des Programmes enkodiert. Um die Struktur zu definieren, und auch zu ihrer Erkennnung, benutzt man kontextfreie Grammatiken (KFG). KFGs haben den Vorteil, dass sie ein einfacher und verständlicher Formalismus sind, für viele Fälle eine Lösung für das Wortproblem in O(n) liegt und es etliche Tools gibt, die Parser aus KFGs konstruieren (z.B. yacc, bison, antlr, javacc).

Diese Grammatik klammert Operatoren nach links und beachtet Punkt- vor Strichrechnung.

\begin{array}{ll}
T & \to E | T + E \\
E & \to F | E * F \\
F & \to x | 2 | (T)
\end{array}

Für die Eingabe x + x + x erhält man dann diesen Ableitungsbaum:

Image:2-Example-Arithmectic-Gramm.png

Arten von Parsern

Es existieren zwei grundsätzliche Arten von Parsern, Top-Down/Predictive Parser und Bottom-Up/Shift-Reduce Parser. Top-Down Parser bauen den Ableitungsbaum von der Wurzel zu den Blättern auf (Tiefensuche), während Bottom-Up Parser den Aufbau bei dem linkesten Blatt beginnen und zur Wurzel fortsetzen.

kontextfreie Grammatiken

So wie der Lexer mit regulären Ausdrücken und DFAs arbeitet, brauchen wir für den Parser die kontextfreie Grammatik. Sie wird in diesem Abschnitt definiert.

Definition: Wörter

Sei M ein Alphabet und M * Worte über M. Dann ist

Eine kontextfreie Grammatik ist ein 4-Tupel G = (N,T,P,S), wobei

Konvention: Grammatiksymbole

In den kommenden Abschnitten sind diese Symbole wie folgt definiert:

  • A, B, C, E \in N
  • \xi, \rho, \tau \in T^*
  • \alpha, \beta\, \gamma, \delta, \nu, \mu \in V^*
  • X, Y, Z \in V

Alle angegebenen Grammatikproduktion sind weiterhin Element in P

Die Ableitungsrelation \Rightarrow bezüglich G, ist definiert als

\Rightarrow \subseteq V^* \times V^*: \alpha \Rightarrow \beta genau dann, wenn α = α1Aα2,β = α1γα2 und A \to \gamma. Es gilt weiterhin

  • \overset{*}{\Rightarrow} ist die reflexive und transitive Hülle von \Rightarrow, \alpha_1 \overset{*}{\Rightarrow} \alpha_n, falls eine Ableitung \alpha_1 \Rightarrow \alpha_2 \Rightarrow \ldots \Rightarrow \alpha_n von α1 nach αn,  n \geqslant 1 existiert.
  • \alpha \in V^* ist Satzform, falls S \overset{*}{\Rightarrow} \alpha.

In einer Linksableitungsrelation \Rightarrow^l \subseteq V^* \times V^* wird immer das linkeste Nichtterminal abgeleitet.

\xi{A}\beta \Rightarrow^l \xi\alpha\beta, falls A \to \alpha (beachte, dass \xi \in T^*).

Die Linkssatzform sowie \overset{*}{\Rightarrow}^l sind analog zur Ableitungsrelation definiert.

Top-Down Parser mit Recursive Descent

Dieser Abschnitt spezifiziert einen Parser formal. Wir beginnen damit, den Parser als nicht-deterministische Funktion zu entwerfen um ihn dann zu transformieren und vereinfachen. Schliesslich werden wir ein Kriterium für Determinismus suchen (dies schränkt die Grammatik ein).

Gegeben: KFG G mit L = L(G)

Gesucht: Ein Parser für L.

Definition: Parser

Ein Parser für L ist eine Funktion P_L:T^* \to \mathcal{P}(T^*) mit P_L(w) = \lbrace u | v \in L, w = vu \rbrace.

Man erhält \tau \in P_L(\rho\tau) \Leftrightarrow \rho \in L. Ein Spezialfall ist v \in L \Leftrightarrow \epsilon \in P_L(v).

Notation: [α]

Für \alpha \in V^* betrachte L(\alpha) = \lbrace w \in T^*|\alpha \overset{*}{\Rightarrow}w \rbrace, beziehungsweise [α]: = PL(α).

Man kann nun aus der Definition und Struktur von α mit konstruktiver Induktion (Fallunterscheidung über α) eine Implementierung für [α] bestimmen:

Implementierung: [α]

\begin{array}{lrl}
\alpha = \epsilon: & [\epsilon](w)       & = \lbrace u | \epsilon \overset{*}{\Rightarrow} v, w = vu \rbrace \\
                   &                     & = \lbrace w \rbrace \\ \\
              
\alpha = X\beta:   & [X\beta](w)         & = \lbrace u | X\beta \overset{*}{\Rightarrow} v, w = vu \rbrace \\
                   &                     & = \lbrace u | X \overset{*}{\Rightarrow} v_1, \beta \overset{*}{\Rightarrow} v_2, w = v_1v_2u \rbrace \\
                   &                     & = \lbrace u | X \overset{*}{\Rightarrow} v_1, \beta \overset{*}{\Rightarrow} v_2, w = v_1w', w' = v_2u \rbrace \\
                   &                     & = \lbrace u | X \overset{*}{\Rightarrow} v_1, w = v_1w', \beta \overset{*}{\Rightarrow} v_2, w' = v_2u \rbrace \\
                   &                     & = \lbrace u | w' \in [X](w), u \in [\beta](w') \rbrace \\
                   &                     & = \bigcup \lbrace [\beta](w') | w' \in [X](w) \rbrace \\ \\

\alpha = x \in T:  & [x](x_1 \ldots x_n) & = \begin{cases} \lbrace x_2 \ldots x_n \rbrace & \mbox{falls} x = x_1 \\ \emptyset & \mbox{sonst} \end{cases} \\ \\

\alpha = A \in N:  & [A](w)              & = \lbrace u | A \overset{*}{\Rightarrow} v, w = vu \lbrace \\
                   &                     & = \lbrace u | A \to \beta \in P, \beta \overset{*}{\Rightarrow} v, w = vu \rbrace \\
                   &                     & = \underset{A \Rightarrow \beta}{\bigcup} \lbrace u | \beta \overset{*}{\Rightarrow} v, w = vu \rbrace \\
                   &                     & = \underset{A \Rightarrow \beta}{\bigcup} [\beta](w)
\end{array}

Beispiel: Parser für Ausdrucksgrammatik

\begin{array}{ll}
[T](w) & = [E](w) \cup [T+E](w) \\
       & = [E](w) \cup \bigcup \lbrace [+E](\rho) | \rho \in [T](w) \rbrace \\
{}[E](w) & = [F](w) \cup [E*F](w) \\
{}[F](w) & = [x](w) \cup [2](w) \cup [(T)](w)
\end{array}

Diese Spezifikation wäre einfach in ein Programm umzusetzen. Wie man an diesem Beispiel allerdings gut sieht, gibt es zwei Probleme:

  1. Die Definition des Parsers ist nicht deterministisch: [A](w) exploriert alle Teile der rechten Seite einer Produktion. Dies kann dazu führen, dass die Implementation exponentiell zum Input-String ist.
  2. Parser rufen sich selbst mit dem gleichen Argument auf (siehe z.B. [X](w)), was zu Endlosrekursion führt. Alle Rekursiv-absteigenden Parser, die links-rekursive Regeln enthalten (Regeln, deren linke Seite auch als erstes Symbol der rechten Seite auftaucht), zeigen dieses Verhalten.

Zu beiden Problemen gibt es eine Lösung, die in den kommenden Abschnitten behandelt wird: Auflösen von Links-Rekursion und Lookahead.

Links-Rekursion

Wie vorher bemerkt, führt Links-Rekursion zu einer Endlosschleife im Parser. Hier wird erklärt, wie man sie auflöst.

Eine Grammatik ist linksrekursiv, wenn ein Nichtterminal A \in N existiert, das linksrekursiv ist.

Ein Nichtterminal A \in N ist linksrekursiv, falls gilt A \overset{+}{\Rightarrow} A\alpha, im speziellen ist es direkt linksrekursiv, falls gilt A \to A\alpha \in P.

Um die Linksrekursion zu eliminieren, sind zwei Schritte nötig. Einmal muss die direkte Linksrekursion am Nichtterminal A beseitigt werden, und zum anderen die allgemeine Rekursion, d.h. G muss zykelfrei und frei von ε-Produktionen sein.

Seien A \to A\alpha_1 | \ldots | A\alpha_m | \beta_1 | \ldots | \beta_k alle Regeln für A (mit βi startet nicht mit A). Dann kann man die Linksrekursion entfernen, in dem man die Regeln mit diesen neuen ersetzt - die neue Grammatik erkennt die gleiche Sprache wie die ursprüngliche:

A \to \beta_1 A' | \ldots | \beta_k A'

A' \to \alpha_1A' | \ldots | \alpha_mA' | \epsilon

Image:2-Procedure-Left-Recursion-.png

Wähle die Reihenfolge A_1, \ldots, A_n für Nichtterminale.

for i = 1 to n

for j = 1 to i - 1

\begin{array}{lcl}
T  & \to & ET' \\
T' & \to & +ET' | \epsilon \\
E  & \to & FE' \\
E' & \to & *FE' | \epsilon \\
F  & \to & x | 2 | (T)
\end{array}

Lookahead

Wie vorher bemerkt, ist es ein Problem, dass der Parser nicht weiss, welcher Teil der rechten Seite zum Weitergehen verwendet werden soll. Würde der Parser alle Möglichkeiten ausprobieren, würde das zu einem Rechenaufwand führen, der exponentiell zur Eingabe ist. Um dieses Problem zu lösen, wird der Lookahead eingesetzt. Lookahead bedeutet, dass der Parser einige (k) Symbole im Input vorausschaut, um zu erkennen, welche rechte Regelseite sinnvoll ist und welche nicht. Als Ergebnis erhält man einen Parser, der linear in der Größe der Eingabe arbeitet.

Um den Lookahead zu realisieren, verwenden wir zwei Funktionen, firstk und followk.

Definition: firstk,followk

first_k: V^* \to T^{\leqslant k} (bezüglich einer KFG)

first_k(\alpha) = \lbrace w{\downarrow}k | \alpha \overset{*}{\Rightarrow} w \in T^* \rbrace

follow_k: N \to \mathcal{P}(T^k)

follow_k(A) = \lbrace w | S \overset{*}{\Rightarrow} \alpha{A}\beta, w \in first_k(\beta) \rbrace

Man kann diese Funktionen nun verwenden, um die oben gestellte Frage nach der korrekten rechten Seite zu beantworten. Betrachtet man diese Ableitung: S \overset{*}{\Rightarrow} \rho{A}\beta \Rightarrow \rho\alpha\beta \overset{*}{\Rightarrow} \rho\xi, so dass der Parser [A] die Entscheidung für die Regel A \to \alpha machen muss, wenn er ξ in der Eingabe (genauer die ersten k Zeichen von ξ) sieht. Wir können nun berechnen, das das k-Präfix von ξ folgende Eigenschaft hat:

Eigenschaft: k-Präfix

\xi{\downarrow}k \in first_k(\alpha\beta) = (first_k(\alpha)first_k(\beta)){\downarrow}k \subseteq (first_k(\alpha)follow_k(A)){\downarrow}k

Das führt uns zur Definition der Lookahead-Menge einer Produktion und starken LL(K)-Grammatiken (das erste L steht für "von links nach rechts parsbar", das zweite für "generiert Linksableitung").

Definition: LL(k) Lookahead

Der LL(k) Lookahead einer Produktion berechnet sich wie folgt:

LLLA_k(A \to \alpha) := (first_k(\alpha)follow_k(A)){\downarrow}k

Eine Grammatik ist eine starke LL(K)-Grammatik, wenn für alle Regeln A \to \alpha, A \to \beta, \alpha \neq \beta gilt: LLLA_k(A \to \alpha) \cap LLLA_k(A \to \beta) = \emptyset.

Wir können nun eine deterministische Parserfunktion für Nichtterminale konstruieren:

Konstruktion: deterministischer Parser


[A](w) = 
\begin{cases} 
[\alpha_1](w), & w{\downarrow}k \in LLLA_k(A \to \alpha_1) \\
\vdots \\
{}[\alpha_n](w), & w{\downarrow}k \in LLLA_k(A \to \alpha_n)
\end{cases}

Definition: LL(K) Grammatik

Eine KFG ist eine LL(K)-Grammatik, falls für alle Ableitungen der Form:

S \overset{*}{\Rightarrow}^l wA\alpha \Rightarrow^l w\beta\alpha \overset{*}{\Rightarrow}^l wu

S \overset{*}{\Rightarrow}^l wA\alpha \Rightarrow^l w\gamma\alpha \overset{*}{\Rightarrow}^l wv

gilt, dass u{\downarrow}k = v{\downarrow}k impliziert β = γ.

Satz: LL(1)

Jede LL(1) Grammatik ist auch stark LL(1).

Beispiel: LL(2)

Für k \geqslant 2 gibt es Grammatiken, die nicht stark LL(k) sind.

Betrachte G mit den Produktionen S \to aAaa | bAba, A \to b | \epsilon. G ist LL(2), da die in fett gehaltenen Abschnitte in beiden Fällen disjunkt sind:

S \Rightarrow aAaa \begin{array}{ll} & a\mathbf{ba}a \\ \nearrow \\ \searrow \\ & a\mathbf{aa} \end{array}


S \Rightarrow bAba \begin{array}{ll} & b\mathbf{bb}a \\ \nearrow \\ \searrow \\ & b\mathbf{ba} \end{array}

Die Bedingung für stark LL(2) ist jedoch nicht erfüllt, da die Lookaheads für die Produktionen von A nicht disjunkt sind:

LLLA_2(A \to b) = (first_2(b)follow_2(A)){\downarrow}k = \lbrace ba, bb \rbrace

LLLA_2(A \to \epsilon) = (first_2(\epsilon)follow_2(A)){\downarrow}k = \lbrace aa, ba \rbrace

Implementierung des Lookaheads

Die Implementierung von firstk betreiben wir ähnlich wie beim Parser durch Fallunterscheidung über α.

Implementierung: firstk

\begin{array}{lrl}
\alpha = \epsilon: & first_k(\epsilon) & = \lbrace \epsilon \rbrace \\
\\
\alpha = X\beta:   & first_k(X\beta)   & = \lbrace (uv){\downarrow}k | X \overset{*}{\Rightarrow} u, \beta \overset{*}{\Rightarrow} v \rbrace \\
                   &                   & = \lbrace (u{\downarrow}k)(v{\downarrow}k) | X \overset{*}{\Rightarrow} u, \beta \overset{*}{\Rightarrow} v \rbrace \\
                   &                   & = (first_k(X)first_k(\beta)){\downarrow}k
\end{array}

Wir müssen nun noch eine Fallunterscheidung über X durchführen:

\begin{array}{lrl}
X = a \in T: & first_k(a) & = \lbrace a \rbrace \\
\\
X = A \in N: & first_k(A) & = \lbrace w{\downarrow}k | A \overset{*}{\Rightarrow} w \rbrace \\
             &            & = \lbrace w{\downarrow}k | A \Rightarrow \alpha \overset{*}{\Rightarrow} w, A \to \alpha \rbrace \\
             &            & = \underset{A \to \alpha}{\bigcup} first_k(\alpha)
\end{array}

Wie man leicht sieht, hat diese Implementierung noch ein Problem: sie ist rekursiv. Man kann aber die Implementierung von firstk als Gleichungssystem mit der Unbekannten first_k(A), A \in N auffassen. Dann ist eine Lösung der Vektor \overline{L} = (L_A \subseteq T^{\leqslant k} | A \in N) \in \textstyle \prod_{A \in N} \mathcal{P}(T^{\leqslant k}) von Mengen von terminalen Strings.

Hier nutzen wir eine Fixpunktiteration, um den Vektor \overline{L} zu berechnen. Um die nächste Iterationsstufe (LA') zu erreichen, müssen wir jeweils auf die aktuelle (LA) zurückgreifen:

\begin{array}{ll}
L_T' & = (L_EL_{T'}){\downarrow}1 \\
L_{T'}' & = (+L_EL_{T'}){\downarrow}1 \cup \lbrace \epsilon \rbrace \\
L_E' & = (L_FL_{E'}){\downarrow}1 \\
L_{E'}' & = (*L_FL_{E'}){\downarrow}1 \cup \lbrace \epsilon \rbrace \\
L_F' & = \lbrace x \rbrace \cup \lbrace 2 \rbrace \cup ((L_T)){\downarrow}1 = \lbrace x, 2 \rbrace \cup ((L_T)){\downarrow}1
\end{array}

Berechne first1(A) für alle Nichterminale A \in N:


\begin{array}{c|c|c|c|c|c} 
  & T            & T'            & E             & E'            & F \\ 
0 & \emptyset    & \emptyset     & \emptyset     & \emptyset     & \emptyset \\ 
1 & \emptyset & \lbrace \epsilon \rbrace & \emptyset & \lbrace \epsilon \rbrace & \lbrace 2,x \rbrace \\ 
2 & \emptyset & \lbrace \epsilon \rbrace & \lbrace 2,x \rbrace & \lbrace *, \epsilon \rbrace & \lbrace 2,x \rbrace \\ 
3 & \lbrace 2, x \rbrace & \lbrace +, \epsilon \rbrace & \lbrace 2,x \rbrace & \lbrace *, \epsilon \rbrace & \lbrace 2,x \rbrace \\ 
4 & \lbrace 2, x \rbrace & \lbrace +, \epsilon \rbrace & \lbrace 2,x \rbrace & \lbrace *, \epsilon \rbrace & \lbrace 2,x, ( \rbrace \\ 
5 & \lbrace 2, x \rbrace & \lbrace +, \epsilon \rbrace & \lbrace 2,x,( \rbrace & \lbrace *, \epsilon \rbrace & \lbrace 2,x, ( \rbrace \\
6 & \lbrace 2, x, ( \rbrace & \lbrace +, \epsilon \rbrace & \lbrace 2,x,( \rbrace & \lbrace *, \epsilon \rbrace & \lbrace 2,x, ( \rbrace
\end{array}

Nach Schritt 6 verändern sich die Mengen nicht mehr - der Fixpunkt ist erreicht. Um den Lookahead zu berechnen, brauchen wir noch die follow1 Mengen, diese berechnen sich ähnlich:

follow_k'(A) = \bigcup_{B \to \beta A\alpha \in P} first_k(\alpha)follow_k(B)

Für unsere modifizierte Ausdrucksgrammatik folgen deshalb diese follow1' Mengen:


\begin{array}{ll}
follow_1'(T) & = (first_1(\ )\ )follow_1(F)){\downarrow}1 \\
follow_1'(T') & = follow_1(T) \cup follow_1(T') \\
follow_1'(E) & = (first_1(T')follow_1(T)){\downarrow}1 \cup (first_1(T')follow_1(T')){\downarrow}1 \\
follow_1'(E') & = follow_1(E) \cup follow_1(E') \\
follow_1'(F) & = (first_1(E')follow_1(E)){\downarrow}1 \cup (first_1(E')follow_1(E')){\downarrow}1 
\end{array}

Berechne follow1(A) für alle Nichtterminale A \in N:


\begin{array}{c|c|c|c|c|c} 
  & T            & T'            & E             & E'            & F \\ 
0 & \lbrace \epsilon \rbrace^{*)}    & \emptyset     & \emptyset     & \emptyset     & \emptyset \\ 
1 & \lbrace \epsilon \rbrace & \lbrace \epsilon \rbrace & \lbrace +,\epsilon \rbrace & \emptyset & \emptyset \\ 
2 & \lbrace \epsilon \rbrace & \lbrace \epsilon \rbrace & \lbrace +,\epsilon \rbrace & \lbrace +,\epsilon \rbrace & \lbrace +,*,\epsilon \rbrace \\ 
3 & \lbrace \epsilon, ) \rbrace & \lbrace \epsilon \rbrace & \lbrace +,\epsilon \rbrace & \lbrace +,\epsilon \rbrace & \lbrace +,*,\epsilon \rbrace \\ 
4 & \lbrace \epsilon, ) \rbrace & \lbrace \epsilon, ) \rbrace & \lbrace +,\epsilon, ) \rbrace & \lbrace +,\epsilon \rbrace & \lbrace +,*,\epsilon \rbrace \\ 
5 & \lbrace \epsilon, ) \rbrace & \lbrace \epsilon, ) \rbrace & \lbrace +,\epsilon, ) \rbrace & \lbrace +,\epsilon, ) \rbrace & \lbrace +,*,\epsilon, ) \rbrace
\end{array}

* )In der follow Menge des Startsymbols muss ε enthalten sein.

Auch hier haben wir einen Fixpunkt erreicht und die follow1 Mengen erhalten. Nun können wir den Lookahead berechnen:

\begin{array}{ll}
LLLA_1(T' \to +ET') & = (first_1(+ET')follow(T')){\downarrow}1 = \lbrace + \rbrace \\
LLLA_1(T' \to \epsilon) & = (first_1(\epsilon)follow(T')){\downarrow}1 = \lbrace \epsilon, ) \rbrace \\
LLLA_1(E' \to *TE') & = (first_1(*TE')follow(E')){\downarrow}1 = \lbrace * \rbrace \\
LLLA_1(E' \to \epsilon) & = (first_1(\epsilon)follow(E')){\downarrow}1 = \lbrace +, \epsilon, ) \rbrace
\end{array}

Wie man hier sieht, ist die Grammatik stark LL(1), d.h. LL(1). Der rekursiv-absteigende Parser kann also mit einem Symbol Lookahead deterministisch werden.

Bottom-Up / Shift Reduce Parser

Der Bottom-Up Parser baut eine Rechtsableitung von den Blättern zur Wurzel auf (d.h. rückwärts). Die Startproduktion S \to \alpha ist die letzte Produktion, die der Parser bearbeitet. Man kann sich den Parser als Stackmaschine vorstellen:

Startkonfiguration: \epsilon \bullet w, wobei ε den leeren Stack und w das Eingabewort darstellt. Der Parser geht dann wie folgt vor:

In Konfiguration \gamma \bullet w

  • Gibt es eine rechte Regelseite auf dem Stack γ = αβ und es existiert eine Regel A \to \beta, dann ist der neue Zustand \alpha{A} \bullet w (reduce)
  • Ist die Eingabe nicht leer, kann sie aufgespalten werden in ein Symbol und den Rest: w = aw'. Der neue Zustand ist dann  \gamma{a} \bullet w' (shift)
  • Falls \gamma \bullet w = S \bullet \epsilon wurde das Eingabewort erkannt

Man wiederholt die einzelnen Schritte bis keine weitere Aktion mehr möglich ist. Dabei kann nichtdeterministisches Verhalten auftreten, weil ein shift fast immer möglich ist, bei der Aufteilung γ = αβ (reduce) und wenn es mehrere Nichtterminale mit gleicher rechter Seite gibt.

parse 2 + x * x

\begin{array}{ll}
\bullet 2 + x * x & \mbox{shift} \\
2 \bullet + x * x & \mbox{reduce } F \to 2 \\
F \bullet + x * x & \mbox{reduce } E \to F \\
E \bullet + x * x & \mbox{reduce } T \to E \\
T \bullet + x * x & \mbox{shift} \\
T + \bullet x * x & \mbox{shift} \\
T + x \bullet * x & \mbox{reduce } F \to x \\
T + F \bullet * x & \mbox{reduce } E \to F \\
T + E \bullet * x & \mbox{shift} \\
T + E * \bullet x & \mbox{shift} \\
T + E * x \bullet \epsilon & \mbox{reduce } F \to x \\
T + E * F \bullet \epsilon & \mbox{reduce } E \to E * F \\
T + E \bullet \epsilon & \mbox{reduce } T \to T + E \\
T \bullet \epsilon & \mbox{accept}
\end{array}

Der so beschriebene Parser ist auch bekannt als LR Parser (L für "von links nach rechts", R für "Rechtsableitung"). Die Arbeitsweise dieses Stackparsers wird vollständig durch einen endlichen Automaten bestimmt, der auf dem Stack arbeitet: dem charakteristischen Automaten (CA) einer Grammatik. Generalvoraussetzung ist, dass jede Grammatik startseparierbar ist.

Definition: startseparierbar

Eine KFG G = (N,T,P,S) ist startseparierbar, falls S auf keiner rechten Regelseite in P vorkommt. Jede Grammatik kann startsepariert werden durch G' = (N \cup \lbrace S' \rbrace, T, P \cup \lbrace S' \to S \rbrace, S')

Definition: Griff, erfolgreiches Präfix

S \overset{*}{\Rightarrow}^r \gamma{Aw} \Rightarrow^r \gamma\alpha{w} mit A \to \alpha

α ist Griff für A \to \alpha. Jedes Präfix γα ist ein erfolgreiches Präfix.

Ein kontextfreies Item ist eine Produktion mit einer Position auf der rechten Regelseite:

\begin{array}{ll}
A \to \alpha\beta & \rightarrowtail A \to \alpha \bullet \beta \\
A \to \epsilon & \rightarrowtail A \to \bullet
\end{array}

Ein predict Item ist ein Item der Form A \to \bullet \gamma, ein reduce Item der Form A \to \gamma \bullet.

Definition: gültiges Item

Ein Item A \to \alpha \bullet \beta ist gültig für ein erfolgreiches Präfix S \overset{*}{\Rightarrow}^r \gamma{Aw} \Rightarrow^r \gamma\alpha\beta{w}. Insbesondere gilt: Falls A \to \alpha \bullet gültig: S \overset{*}{\Rightarrow}^r \gamma{Aw} \Rightarrow^r \gamma\alpha{w}.

Der charakteristischer Automat Char(G) = (Q,Σ,q0,δ,F) von G erkennt die erfolgreichen Präfixe mit Griff am Ende.

Beispielgrammatik:

\begin{array}{ll}
S & \to E \\
E & \to F | E * F \\
F & \to x | (E)
\end{array}

Image:2-Example-Characteristic-Au.png

LR(0) Parser

Es gilt nun, das Problem des nicht-Determinismus zu lösen. Als ersten Schritt konstruieren wir eine deterministische Version des charakteristischen Automaten, den LR(0) Parser

Definition: predict

Die predict Operation fügt einem Zustand alle durch ε Transitionen erreichbaren Items hinzu: predict(q) = \lbrace B \to \bullet \gamma | A \to \alpha \bullet C\beta \in q, A \to \alpha \bullet C\beta \Downarrow^+ B \to \bullet \gamma \rbrace, wobei gilt: A \to \alpha \bullet C\beta \Downarrow C \to \bullet \eta \Leftrightarrow C \to \eta \in P (d.h. Item_1 \Downarrow Item_2 bedeutet, dass man im CA von Item1 mit Hilfe einer ε-Transition zu Item2 gelangt).

\begin{array}{ll}
predict(S \to \bullet E) & = \lbrace E \to \bullet F, E \to \bullet E * F, F \to \bullet x, F \to \bullet (E) \rbrace \\
predict(E \to E * \bullet F) & = \lbrace F \to \bullet x, F \to \bullet (E) \rbrace 
\end{array}

Definition: Closure einer Item-Menge

Der Abschluss einer Item-Menge q ist definiert als \overline{q} = q \cup predict(q).

Definition: LR(0) Automat

Der LR(0) Automat zu G ist der DFA A = (Q,Σ,δ,q0,F) mit

  • Q = \lbrace q \subseteq Items(G) | q = \overline{q} \rbrace
  • Σ = V (Der Automat arbeitet auf Stackworten)
  • \delta(q, X) = \overline{\lbrace A \to \alpha{X} \bullet \beta | A \to \alpha \bullet X\beta \in q \rbrace }
  • q_0 = \overline{\lbrace S' \to \bullet S \rbrace}
  • F = \lbrace q \in Q | A \to \gamma \bullet \in q \rbrace

Mit dem LR(0) Automaten kann man jetzt einen Parser konstruieren. Allerdings ist dieser nur deterministisch, falls die Grammatik LR(0) ist - ansonsten treten Konflikte in den Zuständen des Automaten auf.

Beispiel: LR(0) Automat mit Konflikten

Hierzu verwenden wir den Automaten aus dem vorigen Beispiel und bauen ihn in einen LR(0) Automaten um:

Image:2-Example-LR(0)-Automaton.png

Die Indizes in diesem Automaten werden für ein späteres Beispiel benötigt.

Die auftretenden Konflikte lassen sich in shift-reduce und reduce-reduce Konflikte aufteilen. Im obigen Beispiel enthält der Zustand \lbrace S \to E \bullet, E \to E \bullet * F \rbrace einen shift-reduce Konflikt, da sowohl ein shift auf dem Symbol * ausgeführt werden kann, wie auch ein reduce auf dem Nichtterminal E.

Definition: shift-reduce, reduce-reduce Konflikt
Definition: LR(0) Grammatik

Eine Grammatik G heisst LR(0), falls alle Zustände des zu G gehörenden LR(0) Automaten konfliktfrei sind.

Parsen mit dem Automaten

Um eine Eingabe mit Hilfe des LR(0) Automatens zu parsen, wenden wir so lange eine parse Funktion auf einen Stack mit Automatenzuständen und den verbleibenden Eingabestring an, bis die Eingabe leer ist und der oberste Zustand auf dem Stack ein Item S \to S' \bullet enthält.

<div style="padding-left:4px;" id="Definition: goto & parse">Definition: goto & parse


\begin{array}{lccl}
\mbox{parse}(stack,w) & = & \mathbf{let}\ & q = \mbox{top}(stack)\ \mathbf{in} \\
&&     & \bigvee \lbrace \mbox{goto}(A, \mbox{pop}(|\alpha|, stack)^\dagger, w) | A \to \alpha \bullet \in q \rbrace \\
&& \or & \bigvee \lbrace \mbox{goto}(a, stack, w') | w = aw', A \to \alpha \bullet a\beta \in q \rbrace \\
&& \or & \bold{if} \ w = \epsilon \and S' \to S \bullet \in q\ \bold{then}\ \mbox{accept} \\
\\
\mbox{goto}(X, stack, w) & = & \mathbf{let}\ & q = \mbox{top}(stack)\ \mathbf{in} \\
&&     & \mbox{parse}(\delta(q,X), stack, w)
\end{array}

{}^\dagger Die in dieser Definition verwendete Funktion pop gibt den Stack (zweiter Parameter) ohne die obersten | α | (erster Parameter) Einträge zurück.

</div>

Beispiel: Parsen der Eingabe x * x

Die hier verwendeten Zustandsindizes finden sich im obigen Graphen an den Zuständen wieder.



\begin{array}{lrcl}
\bold{Stack} & \bold{verbleibende}\ \bold{Eingabe} & \bold{Aktion} & \bold{neuer Zustand}  \\
q_0 & x * x & \mbox{shift} & q_4 = \mbox{goto}(x, q_0, * x) \\
q_0q_4 & * x & \mbox{reduce} & q_3 = \mbox{goto}(F, q_0, * x) \\
q_0q_3 & * x & \mbox{reduce} & q_1 = \mbox{goto}(E, q_0, * x) \\
q_0q_1 & * x & \mbox{shift} & q_5 = \mbox{goto}(*, q_0q_1, x) \\
q_0q_1q_5 & x & \mbox{shift} & q_4 = \mbox{goto}(x, q_0q_1q_5, \epsilon) \\
q_0q_1q_5q_4 & \epsilon & \mbox{reduce} & q_2 = \mbox{goto}(F, q_0q_1q_5, \epsilon) \\
q_0q_1q_5q_2 & \epsilon & \mbox{reduce} & q_1 = \mbox{goto}(E, q_0, \epsilon) \\
\bot & \epsilon & \mbox{accept} & 
\end{array}

Kanonisches LR(k) Parsing

Um die im vorherigen Abschnitt beschriebenen Konflikte aufzuheben, greifen wir auf das bereits bekannte Mittel Lookahead zurück. Wir betrachten hier den kanonischen Weg, Lookahead zu implementieren, der aber sehr aufwendig in der Berechnung ist. Die folgenden Abschnitte zeigen dann effizientere Möglichkeiten auf.

Definition: LR(k) Grammatik

Eine Grammatik G heisst LR(k) Grammatik, falls für alle Ableitungen der Form:

S \overset{*}{\Rightarrow}^r \gamma{Aw} \Rightarrow^r \gamma\alpha{w}

S \overset{*}{\Rightarrow}^r \beta{Bv} \Rightarrow^r \gamma\alpha{v}

gilt, dass v{\downarrow}k = w{\downarrow}k impliziert A = B,β = γ und v = w (γα ist der Stackinhalt, Wissen über den Stackinhalt und die nächsten k Symbole reicht zur Entscheidung, welche Produktion greift).

LR(k) Grammatiken haben einige interessante Eigenschaften:

Die nun folgende Definition des LR(k) DFA folgt generell der des LR DFA, wobei der grösste Unterschied die hinzugefügten Lookahead Mengen sind.

Definition: LR(k) Item

Ein LR(k) Item ist ein Tupel aus Produktion, Position und Lookahead Menge L \subseteq T^{\leqslant k}:  A \to \alpha \bullet \beta (L).

Definition: gültiges Item

Ein Item A \to \alpha \bullet \beta (L) ist gültig, für ein erfolgreiches Präfix γα, falls S \overset{*}{\Rightarrow}^r \gamma\alpha\beta{w} mit w{\downarrow}k \in L.

Lookahead Mengen werden durch predict propagiert:

<div style="padding-left:4px;" id="Definition: predict">Definition: predict

Sei q eine Menge von LR(k) Items. Dann ist predict(q) = \lbrace C \to \alpha \bullet (M) | B \to \beta \bullet A\gamma (L) \in q, B \to \beta \bullet A\gamma (L) \Downarrow^+ C \to \alpha \bullet (M) \rbrace mit A \to \alpha \bullet B\beta (L) \Downarrow B \to \bullet \gamma ((first_k(\beta){L})\downarrow k).

</div>

Definition: Closure einer Item-Menge

Die Closure einer Item-Menge q ist definiert als \overline{q} = q \cup predict(q).

Die Definition des LR(k) Automaten ist sehr ähnlich der des LR(0) Automaten:

Definition: LR(k) Automat

Der LR(k) Automat zu G ist der DFA A = (Q,Σ,δ,q0,F) mit

  • Q = \lbrace q \subseteq Items(G) | q = \overline{q} \rbrace
  • Σ = V (Der Automat arbeitet auf Stackworten)
  • \delta(q, X) = \overline{\lbrace A \to \alpha{X} \bullet \beta (L) | A \to \alpha \bullet X\beta (L) \in q \rbrace }
  • q_0 = \overline{\lbrace S' \to S (\lbrace \epsilon \rbrace)\rbrace}
  • F = \lbrace q \in Q | A \to \gamma \bullet (L) \in q \rbrace

Auch ein LR(k) Automat kann Konflikte enthalten, die zu einem nicht-Determinismus im Parser führen:

Definition: LR(k) Konflikte

Praktikable Methoden für LR Parsing mit Lookahead

Da die kanonischen Parser häufig zu unhandlich grossen Zustandsautomaten führen, suchen wir einen anderen Weg zum Konstruieren der Parser. Hier bieten sich zwei Methoden an: Simple LR Parsing (SLR) und Lookahead LR Parsing (LALR). Beide arbeiten mit LR(0) Parsern, wie sie weiter oben vorgestellt wurden.

Simple LR

SLR erweitert die Items eines LR(0) Automaten um Lookahead-Informationen in Form der followk Mengen, das heisst: A \to \alpha \bullet \beta (follow_k(A)).

Beispiel: Simple LR(k)

Hier wird der Konflikt aus dem früheren Beispiel durch SLR aufgelöst, die Grammatik ist also SLR(1):

Image:2-Example-SLR-Resolution.png

Lookahead LR

Der Lookahead LR(k) (LALR) wird mit k = 1 in fast allen Parsergeneratoren eingesetzt. Anstelle von followk verwendet er Lookahead-Informationen, die von der Ableitung abhängig sind. Diese Informationen werden aus dem LR(0) Automaten gewonnen.

Definition: LAk

Mit A \to \alpha \bullet \beta \in q gilt:

LA_k(q, A \to \alpha \bullet \beta) = \lbrace w{\downarrow}k | q = \delta^*(q_0, \gamma\alpha), S' \overset{*}{\Rightarrow}^r \gamma{Aw} \rbrace

Das heißt wir betrachten alle Pfade, die von q0 nach q führen und das Suffix α besitzen; für jedes resultierendes Pfadpräfix γ sehen wir uns alle Ableitungen vom Startsymbol an, die die Form S' \overset{*}{\Rightarrow}^r \gamma Aw haben - die k-Präfixe der w sind dann in der Lookahead Menge des Items A \to \alpha \bullet \beta in q.

Berechnung: LAk

Man kann den LAk mit drei Schritten berechnen:

  1. Führe LA_k(q, A \to \alpha \bullet \beta) zurück auf LA_k(q', A \to \bullet \alpha \beta):

    \begin{array}{ll}
& La_k(q, A \to \alpha \bullet \beta) \\
= & \lbrace w{\downarrow}k | S' \overset{*}{\Rightarrow}^r \gamma{Aw}, q = \delta^*(q_0, \gamma\alpha) \rbrace \\
= & \lbrace w{\downarrow}k | S' \overset{*}{\Rightarrow}^r \gamma{Aw}, q' = \delta^*(q_0, \gamma), q = \delta^*(q', \alpha) \rbrace \\
= & \lbrace w{\downarrow}k |LA_k(q', A \to \bullet \alpha\beta), q = \delta^*(q', \alpha) \rbrace \\
= & \underset{q = \delta^*(q', \alpha)}{\bigcup} LA_k(q', A \to \bullet \alpha\beta)
\end{array}
  2. Für die predict Items kann ein Gleichungssystem aufgestellt werden, das mit Fixpunktiteration gelöst wird: Setze \widehat{LA_k}(q,A) = LA_k(q, A \to \bullet \gamma). Unterscheide nach der Herkunft der predict Items:
    • Start-Item S' \to \bullet S:

      \begin{array}{cl}
& \widehat{LA_k}(q_0, S')\\
= & \lbrace w{\downarrow}k | S' \overset{*}{\Rightarrow}^r \gamma{Sw}, q_0 = \delta^*(q_0, \gamma) \rbrace\\
\overset{\gamma = w = \epsilon}{=} & \lbrace \epsilon \rbrace
\end{array}
    • A \neq S', d.h. es existiert eine Regel A \to \bullet \eta \in q - diese muss durch die Abschlussoperation (\overline{q}) hier her gelangt sein, was wiederum heisst, dass eine Regel B \to \alpha \bullet A\beta \in q existiert, mit γα = γ0:

      \begin{array}{ll}
& \widehat{LA_k}(q, A)\\
= & \lbrace w{\downarrow}k | S' \overset{*}{\Rightarrow}^r \gamma_0{Aw}, q = \delta^*(q_0, \gamma_0) \rbrace \\
= & \lbrace w{\downarrow}k | S' \overset{*}{\Rightarrow}^r \gamma_0{Bv} \Rightarrow^r \gamma\alpha{A}\beta{v} \overset{*}{\Rightarrow}^r \gamma\alpha{Aw}, q = \delta^*(q_0, \gamma\alpha) \rbrace
\end{array}
      Wir wissen, dass gilt: v{\downarrow}k \in LA_k(q', B \to \alpha \bullet A\beta), q' = \delta^*(q_0, \gamma\alpha), also gilt auch: w \in (first_k(\beta)LA_k(q, B \to \alpha \bullet A\beta)){\downarrow}k.
      \begin{array}{ll}
= & \textstyle \bigcup_{B \to \alpha \bullet A\beta \in q} (first_k(\beta)LA_k(q, B \to \alpha \bullet A\beta ) ){\downarrow}k \\
= & \textstyle \bigcup_{B \to \alpha \bullet A\beta \in q} \textstyle \bigcup_{q = \delta^*(q', \beta)} (first_k(\beta)\widehat{LA_k}(q', B)){\downarrow}k
\end{array}
  3. Für die reduce Items kann man setzen: LA_k(q, A \to \alpha \bullet) = \textstyle \bigcup_{q = \delta^*(q', \alpha)} \widehat{LA_k}(q', A).

Für k = 1 gibt es Algorithmen, die das in den Schritten 2 und 3 erstellte Gleichungssystem in linearer Zeit lösen.

Ausgabe des Parsers

Definition: parse

Ein Parser erzeugt eine Syntaxrepräsentation, d.h. parse: T^* \to D, so dass unparse: D \to T^*, mit parse \circ unparse = \mbox{id}_D.

Es gibt zwei Möglichkeiten der Syntaxrepräsentation, einen Ableitungsbaum und die abstrakte Syntax.

Ableitungsbaum

Definition: Ableitungsbaum

Ein Ableitungsbaum zu (N,T,P,S) ist ein endlicher, geordneter, gerichteter Baum mit Knotenmenge V, so dass jeder Knoten mit einer Produktion \pi \in P markiert ist.

  • Die Wurzel ist mit der Startporduktion S\to S' markiert
  • Für jeden Knoten gilt: Mit mark(v) = A_0 \to w_0A_1w_1\ldots{A_n}w_n, hat ein Knoten v die Kinder v_1,\ldots,v_n mit Markierungen mark(v_i) = A_i \to \alpha_i.

Man kann die Produktion A_0 \to w_0A_1\ldots{A_n}w_n als n-stelligen Operator auffassen.

Beispiel: Reduktionen für 2 + x * x

Wir benutzen hierzu die bekannte Ausdrucksgrammatik. Erzeuge den Ableitungsbaum durch Anwenden der reduzierenden Produktionen auf einen Hilfsstack. Der n-stellige Operator wirkt auf die obersten n Stackeinträge.

Image:2-Example-Derivation-Tree.png

Der Ableitungsbaum enthält auf diese Weise nutzlose Informationen (d.h. der Term hat keine Bedeutung, im Baum eingekreist), nämlich über Kettenproduktionen und Klammerstrukturen. Stattdessen kann man eine andere Methode wählen, die im nächsten Abschnitt behandelt wird.

abstrakte Syntax

Die Grammatik für die abstrakte Syntax enthält nur die wesentlichen Informationen:

A \to 2 | x | A * A | A + A

Für 2+x*x ergibt sich dann folgende Struktur:

<div style="padding-left:4px;" id="Beispiel: abstrakte Syntax für 2+x*x">Beispiel: abstrakte Syntax für 2+x*x

Image:2-Example-Abstract-Syntax.png

</div>

Diese Struktur entspricht auch Typdefinitionen in OCaml:

Code: arith
type arith = Two | X | Add of arith * arith | Mul of arith * arith

Und der Ausdruck würde dann folgendermassen aussehen:

<div style="padding-left:4px;" id="Beispiel: 2+x*x">Beispiel: 2+x*x
Add Two (Mul x x)

</div>

Semantische Analyse

Kapitel auf einer Seite lesen


Attribuierte Grammatiken

Ein Parser der nur als Erkenner fungiert ist nicht ausreichend für einen Compiler - seine Ausgabe verrät nichts über die Natur der geparsten Eingabe, nur ob sie zu der durch die unterliegenden Grammatik definierten Sprache gehört. Für einen Compiler muss ein Parser den Syntaxbaum, den er intern generiert hat, bereitstellen. Daher führt dieser Abschnitt eine erweiterte Notation der kontextfreien Grammatik ein: die attribuierte Grammatik. Eine attribuierte Grammatik verbindet zusätzliche Werte (die Attributsinstanzen) mit den Knoten eines Syntaxbaum und stellt Regeln bereit, die diese zu einander in Beziehung setzen. Ein Parser in einem Compiler berechnet die Werte der Attributsinstanzen und gibt diese zum Aufrufer zurück.

Attribuierte Grammatiken besitzen eine beträchtliche Menge an verwirrenden Formalismen. Einige Beispiele sollen die zentrale Idee verdeutlichen.

Wir betrachten eine Grammatik für konstante arithmetische Ausrücke. Aus technischen Gründen hat nur eine Zahl ein Literal: 42. Eine attribuierte Grammatik kann nun beschreiben wie man tatsächlich den Wert eines solchen Ausdrucks berechnet.

\begin{array}{lclcl}
\langle exp \rangle & ::= & \langle term\rangle  & & \langle exp\rangle .v = \langle term\rangle .v \\
 & | & \langle term\rangle  + \langle exp\rangle  & & \langle exp\rangle_{LHS} .v = \langle term\rangle .v + \langle exp\rangle_{RHS} .v \\
 & | & \langle term\rangle  - \langle exp\rangle  & & \langle exp\rangle_{LHS} .v = \langle term\rangle .v - \langle exp\rangle_{RHS} .v \\
\\
\langle term\rangle  & ::= & \langle prod\rangle  & & \langle term\rangle .v = \langle prod\rangle .v \\
 & | & \langle prod\rangle  * \langle term\rangle  & & \langle term\rangle_{LHS} .v = \langle prod\rangle .v \cdot \langle term\rangle_{RHS} .v \\
 & | & \langle prod\rangle  / \langle term\rangle  & & \langle term\rangle_{LHS} .v = \langle prod\rangle .v / \langle term\rangle_{RHS} .v \\
\\
\langle prod\rangle  & ::= & 42 & & \langle prod\rangle .v = 42 \\
 & | & (\langle exp\rangle ) & & \langle prod\rangle .v = \langle exp\rangle .v \\
\end{array}

\begin{array}{lclcl}
\langle bit\rangle  & ::= & 0 & & \langle bit\rangle .v = 0 \\
 & | & 1 & & \langle bit\rangle .v = 2^{\langle bit\rangle .s} \\
\\
\langle bits\rangle  & ::= & \langle bit\rangle  & & \langle bits\rangle .v = \langle bit\rangle .v \\
& & & & \langle bit\rangle .s = \langle bits\rangle .s \\
& & & & \langle bits\rangle .l = 1 \\
 & | & \langle bits\rangle \langle bit\rangle & & \langle bits\rangle_{LHS} .v = \langle bits\rangle_{RHS} .v + \langle bit\rangle .v \\
& & & & \langle bit\rangle .s = \langle bits\rangle_{LHS} .s \\
& & & & \langle bits\rangle_{RHS} .s = \langle bits\rangle_{LHS} .s + 1 \\
& & & & \langle bits\rangle_{LHS} .l = \langle bits\rangle_{RHS} .l + 1  \\
\\
\langle num\rangle  & ::= & \langle bits\rangle  & & \langle num\rangle .v = \langle bits\rangle .v \\
& & & & \langle bits\rangle .s = 0 \\
 & | & \langle bits\rangle  . \langle bits\rangle  & & \langle num\rangle .v = \langle bits\rangle_{RHS_1} .v + \langle bits\rangle_{RHS_2} .v \\
& & & & \langle bits\rangle_{RHS_1} .s = 0 \\
& & & & \langle bits\rangle_{RHS_2} .s = -\langle bits\rangle_{RHS_2} .l \\ 
\end{array}


Notationen

Definition: Position

Eine Position identifiziert das Vorkommen eines Grammatiksymbols in einer Produktion. Das Symbol wird durch ein \circ direkt davor identifiziert. Folglich identifiziert \langle \circ A \rightarrow \alpha \rangle die linke Seite A und \langle A \rightarrow \alpha \circ X \beta \rangle das X.

Sei Att eine Menge von Attributen. Eine attribuierte Grammatik ist ein Tupel (G,Syn,Inh,\mathcal{R},\mathcal{D}) mit


Für jedes A gilt: Syn(\mathrm{A}) \cap Inh(\mathrm{A}) = \emptyset und Att(\mathrm{A}) := Inh(\mathrm{A}) \cup Syn(\mathrm{A}) ist die Menge der Attribute von A.


Ein Attributvorkommen ist ein Paar p.a das aus der Position innerhalb einer Produktion und einem Attribut besteht. Attributvorkommen muss entweder die Form \langle \circ \mathrm{A} \rightarrow \alpha \rangle .a mit a \in Att(\mathrm{A}) oder \langle A \rightarrow \gamma \circ \mathrm{B} \delta \rangle .a mit a \in Att(\mathrm{B}) haben.


Die Vorkommen einer Produktion fallen in 2 Klassen: Die definierenden Vorkommen Def(\mathrm{A} \rightarrow \alpha) und die angewandten Vorkommen App(\mathrm{A} \rightarrow \alpha).

Def(A \rightarrow \alpha) := \{ \langle \circ A \rightarrow \alpha \rangle .a | a \in Syn(A) \} \cup \{ \langle A \rightarrow \gamma \circ B \delta \rangle .a | \alpha = \gamma B \delta \wedge a \in Inh(B) \}
App(A \rightarrow \alpha):= \{ \langle \circ A \rightarrow \alpha \rangle .a | a \in Inh(A) \} \cup \{ \langle A \rightarrow \gamma \circ B \delta \rangle .a | \alpha = \gamma B \delta \wedge a \in Syn(B) \}


Jedes definierende Vorkommen p.a \in Def(A \rightarrow \alpha) hat eine damit verbundene Attribuierungsregel

p.a = f_{p.a}(p_{1}.a_{1}, \ldots , p_{m}.a_{m})

wobei m irgendeine natürliche Zahl darstellt die mit p.a verbunden ist und alle p_{m}.a_{m} \in App(A \rightarrow \alpha) angewandte Attributvorkommen für die selbe Produktion sind. fp.a muss eine Funktion  {\mathcal{D}}^{a_1} \times \ldots \times {\mathcal{D}}^{a_m} \rightarrow {\mathcal{D}}^{a} sein. \mathcal{R} ist eine Familie aller solcher Attribuierungsregeln die durch die definierenden Attributsvorkommen indexiert werden.

Die Attributabhängigkeiten einer Grammatik sind nach der obrigen Definition in der Bochmann Normal Form: Vererbte Attribute hängen nur von "über" ihnen im Syntaxbaum ab, synthetisierte nur von denen "unter" ihnen. Eine attribuierte Grammatik verleiht einem Ableitungsbaum durch Vorschriften für seiner Knotenbeschriftung eine Bedeutung.

Datei:3-Example-Arithmectic-Gramm.png
Bild 3.1: Eine Attribuierung durch eine Grammatik
Definition: Ableitungsbaum

Ein Ableitungsbaum für eine kontextfreie Grammatik (N,T,P,S) ist ein endlicher, geordneter und gerichteter Baum mit einer Menge Knoten V sodass jeder Knoten mit einer Produktion beschriftet ist. Weiterhin

  • Wenn der Knoten v0 mit A_{0} \rightarrow A_{1} \ldots A_{n} beschriftet ist muss v0 genau n Nachfolger v_1, \ldots, v_n haben sodass vi mit A_i \rightarrow \alpha_i beschriftet ist.
  • in einem vollen Ableitungsbaum ist der Wurzelknoten mit der Start-Produktion  S \rightarrow \alpha beschriftet.

Eine Attributsdekoration verbindet mit jedem Knoten v eines Ableitungsbaum, der mit einer Produktion A \rightarrow \alpha beschriftet ist, eine Attributsinstanz v.a \in \mathcal{D}^a für jedes a \in Att(A). Eine Attributsdekoration ist gültig wenn für jeden Knoten v0, der mit A_0 \rightarrow A_1 \ldots A_n beschriftet ist und v_1, \ldots, v_n als Nachfolger besitzt und für jedes definierende Vorkommen p_{0}.a \in Def(A_0 \rightarrow A_1 \ldots A_n) mit einer verbundenen Attribuierungsregel

p_{0}.a = f_{p_{0}.a}(p_{1}.a_{1}, \ldots,p_{m}.a_{m} )

gilt das

v_{i_0}.a = f_{p_{0}.a}(v_{i_1}.a_{1}, \ldots,v_{i_m}.a_{m} )

wobei die Position pj nicht-terminale Vorkommen von A_{i_j} und damit Knoten v_{i_j} identifiziert.

Bemerkung: Eindeutigkeit einer Attributsdekoration

Eine Attributsdekoration muss nicht eindeutig sein.

Beispiel:


\begin{array}{lcllcl}
A & \rightarrow & B & & B.i & = f(B.s) \\
B & \rightarrow & \epsilon & & B.s_{\epsilon} & = g(B.i) \\
\end{array}

Inh(B) = {i}

Syn(B) = {s}

Falls f(x) = g(x) = x + 1, dann exisitiert keine Attributsdekoration.

Falls f(x) = x + 1,g(x) = x − 1, dann exisitieren unendlich viele Attributsdekorationen.

Dies ist auch ein Beispiel für eine zirkuläre attribuierte Grammatik.

Einschub (nicht im Mitschrieb):

Daher definiert eine attribuierte Grammatik G mit der Startproduktion S \rightarrow A eine meaning function \mathcal{M}:\mathcal{T}^{*} \times {\mathcal{D}}^{Inh(A)} \rightarrow {\mathcal{D}}^{Syn(S)}. Für \xi \in \mathcal{L}(G) initialisiert \mathcal{M}(\xi,v_{1}, \ldots,v_{|Inh(A)|}) den Ableitungsbaum mit Attributsinstanzen für die geerbten Attribute von A und erzeugt Instanzen der synthetisierten Attribute von S die aus der Dekoration des Ableitungsbaums stammen.

Ende Einschub

Eine attribuierte Grammatik ist zirkulär falls es einen Ableitungsbaum t gibt sodass der Graph Dt(t) zirkulär ist. Dabei ist Dt(t) = (V,E) mit

Autoren-Anmerkung(Firefox): Offensichtlich muss hier statt den \ldots in f_{p.a_0} irgendein Bezug auf v_{i_j}.a_{j} vorhanden sein. Intuitiv gibt es eine Kante von jedem Vorkommen rechts des Gleichheitszeichens auf das Vorkommen links des Gleichheitszeichens wenn beide das gleiche Nichtterminal haben. Genauere Definitionen werden ergänzt.

Das Testen einer attribuierten Grammatik auf Zirkularität ist exponentiell !

Berechnung einer Dekoration

Es gibt viele Techniken die tatsächlich solch eine Dekoration eines Ableitungsbaum produzieren, manche von ihnen sind auch sehr gut. Es ist jedoch augenscheinlich wünschenswert die Dekoration während des Parsens zu generieren. Da die Bedeutungsfunktion nur an den Attributsinstanzen der Startproduktion interessiert ist und der Ableitungsbaum selbst kein Teil davon ist, könnte das Generieren der Dekoration während des Parsens das Speichern des Baums vermeiden. Leider benötigen manche generelle attribuierte Grammatiken den ganzen Baum um Attribute zu evaluieren; die Attribuierungsregeln könnten außerdem Zirkularitäten generieren.
Deshalb ist es notwendig die Klasse der attribuierten Grammatiken so zu beschränken das sie für on-the-fly Attributsevaluation zugänglich werden. 2 separate Wege geeignete Restriktionen für attribuierte Grammatiken zu formulieren sind L-attribuierte und S-attribuierte Grammatiken.

L-attribuierte Grammatiken

Eine L-attribuierte Grammatik ist eine attribuierte Grammatik bei der für jede Regel

d = f_d(a_1 \ldots a_{i_d})

mit d von der Form \langle A \rightarrow \alpha \circ B \beta \rangle .a (mit a \in Inh(B))gilt, das jedes aj entweder die Form \langle \circ A \rightarrow \alpha B \beta \rangle .a (mit a \in Inh(A)) oder \langle A \rightarrow \gamma \circ \mathcal{C} \delta B \beta \rangle .a (mit \gamma \mathcal{C} \delta = \alpha und a \in Syn(\mathcal{C})) besitzt. Regeln mit einem d der Form \langle \circ A \rightarrow \alpha \rangle .a haben keine Beschränkungen.

In einer L-attribuierten Grammatik dürfen alle Attributvorkommen nur von ihrem Auftreten auf der linken Seite abhängen (daher auch L-attribuiert). Weil ein rekursiv-absteigender Parser von links nach rechts durch die Grammatikregeln geht, bieten sich L-attribuierte Grammatiken für on-the-fly Attributevaluation durch rekursiv-absteigende Parser an.

Betrachten sie ein Fragment eines rekursiv-absteigenden Parsers für die Grammatikregel A \rightarrow A_1 \ldots A_m. Die Parserfunktion für A nimmt die Eingabefolge und die vererbten Attribute von A als Parameter und gibt die verbleibende Folge und die synthetisierten Attribute von A als Resultat zurück. Zur Vereinfachung nehmen wir an das es nur ein einziges synthetisiertes Attribute s und ein vererbtes Attribute i gibt.


\begin{array}{lcl}
[A] & : & {\mathcal{T}}^{*} \times Inh(A) \rightarrow {\mathcal{T}}^{*} \times Syn(A) \\
\left [ A \right ] (\xi ,inh) & = & \bold{let} \ {inh}_{1} = {f}_{\langle A \rightarrow \circ A_1 \ldots A_m \rangle .i}(inh)\ \bold{in} \\
& & \bold{let} \ ({\xi}_1,{syn}_1) = [A_1](\xi ,{inh}_1)\ \bold{in} \\
& & \bold{let} \ {inh}_2 = f_{\langle A \rightarrow A_1 \circ A_2 \ldots A_m \rangle .i}(inh, {syn}_1)\ \bold{in} \\
& & \vdots \\
& & \bold{let} \ ({\xi}_m , {syn}_m) = \left [ A_m \right ] ({\xi}_{m-1} , {inh}_m) \ \bold{in} \\
& & \bold{let} \ syn = f_{\langle \circ A \rightarrow A_1 \ldots A_m \rangle .s}(inh , {syn}_1 , \ldots , {syn}_m) \ \bold {in} \\
& & ({\xi}_m , syn)
\end{array}

S-attribuierte Grammatiken

Trotz alledem stellen L-attribuierte Grammatiken ein Problem für rekursiv-absteigende Parser dar: Während des Laufs eines rekursiv-absteigenden Parsers muss sich dieser mehrere verschiedene Produktion "merken", von denen jede komplett verschiedene Attribuierungsregeln besitzen kann. Er würde daher alle diese Regeln parallel auswerten müssen - und dadurch viel überflüssige Arbeit erledigen. Allerdings beschränken rekrusiv-aufsteigende Parser die Klasse der attribuierten Grammatiken die sie akzeptieren normalerweise noch weiter: Sie erlauben einfach keine vererbten Attribute.

Eine S-attribuierte Grammatik ist eine attribuierte Grammatik \mathcal{G,S,I,R,D} mit Inh(A) = \emptyset für alle A \in N

S-attribuierte Grammatiken werden auch von Parser-Generatoren wie z.B. yacc verwendet. Bei einer S-attribuierte Grammatiken verläuft die Attributevaluation von unten nach oben im Ableitungsbaum: Es können keine Zirkularitäten auftreten da alle Abhängigkeiten in die gleiche Richtung zeigen. Aus diesem Grund ist es ausreichend nur ein synthetisiertes Attribut pro Nichtterminal zu haben - verschiedene Attribute können einfach durch Zusammenfassungen wie Rekords simuliert werden. Daher nehmen wir im Rest dieser Sektion an, das Syn(A) = {v} für alle A \in N gilt. Außerdem besitzt der vereinfachten Darstellung wegen f_{\langle A \rightarrow \alpha \rangle .v} immer | α | Argumente. Terminalzeichen geben einfach irgendeinen reservierten Wert \bot, der sonst nicht benutzt wird, als ihr Attribut zurück.

Beispiel: Erweiterung des LR-Parsers um die Auswertung von S-attribuierten Grammatiken


\begin{array}{lll}
\bold{parse}(qstk,vstk,w) & = & \\
& \bold{let} \ q & = top (qstk) \ \bold{in} \\  
& & shift \ a \ v \ qstk \ vstk \ w' \ \ falls \ w = a w' \\
& & \ldots 
\end{array}

Autoren-Anmerkung(Firefox): Dieses Beispiel ist zu komplex und muss für bessere Verständlichkeit überarbeitet werden. Interessierte finden es auf S61 des Vorlesungsskripts (Chapter 3: Semantic Analysis) von Prof. Thiemanns Vorlesung

statische semantische Eigenschaften

(vgl. Kapitel 8 in Wilhelm / Maurer)

  • Werte, die zu jedem Programmpunkt (Knoten im AST) aus dem Programmtext berechnet werden können.
  • Wert beschreibt alle während eines Programmlaufs an diesem Programmpunkt eintretenden Werte und Konfigurationen.
Beispiel: Beispiele für Werte
  • Typ eines Ausdrucks
  • Wertebereich eines Ausdrucks (z.B. ob Array-Zugriff OK ist)
  • Exakter Wert eines Ausdrucks (falls nicht möglich: \top)
  • falls der Wert ein Dateihandle ist: ist die Datei offen oder geschlossen ?
Beispiel: Typberechnung für binäre arithmetische Operatoren

Es gibt in unserem Beispiel die Typen INT und REAL.


\begin{array}{clll}
\bold{ bin. \ Operator } & \bold{ 1. Operand } & \bold{ 2. Operand } & \bold{ Ergebnis } \\
+,-,* & INT & INT & INT \\
+,-,* & REAL & INT \vee REAL & REAL \\
+,-,* & INT \vee REAL & REAL & REAL \\
/ & INT \vee REAL & INT \vee REAL & REAL \\
\% & INT & INT & INT \\
\end{array}

Abstrakte Syntax: e ::= i|r|e \odot e

Typen gegeben durch S-attribuierte Grammatik


\begin{array}{lcll}
\langle \circ e \rightarrow i \rangle .typ & = & INT & \\
\langle \circ e \rightarrow r \rangle .typ & = & REAL & \\
\langle \circ e \rightarrow e \odot e \rangle .typ & = & \bold{let} \ t_1 & = \langle e \rightarrow \circ e \odot e \rangle .typ \ \bold{in} \\
& & \bold{let} \ t_2 & = \langle e \rightarrow e \odot \circ e \rangle .typ \ \bold{in} \\
& & & \bold{if} \ \exists \ Eintrag \ t \ fuer \ t_1 \odot t_2 \ in \ Tabelle \\
& & & \bold{then} \ t \\
& & & \bold{else} \ Fehler \ symbolisieren (?) \\
\end{array}


Gültigkeit und Sichtbarkeit von Bezeichnern (Scoping)

Definition: Bezeichner

Ein Vorkommen eines Bezeichners oder Objekts einer Dekoration heißt definierendes Vorkommen (dV) alle anderen Vorkommen heißen angewandte Vorkommen (aV).

Problem: Im Allgemeinen gibt es mehrere definierende Vorkommen eines Bezeichners. Auf welches definierende Vorkommen bezieht sich ein angewandtes Vorkommen ?

Beispiel: mehrdeutige Bezeichner


class \ A \{ int \ a; A(int \ a) \{ \ldots a=1; \ldots \}\}

Der Gültigkeitsbereich eines definierenden Vorkommens eines Bezeichners id (Scope) ist Teil eines Programms, indem sich das angewandte Vorkommen von id auf diese definierenden Vorkommenen beziehen können. Für ein angewandtes Vorkommen kann es mehrere gültige definierende Vorkommen geben, aber nicht alle sind sichtbar. Durch geeignete Sprachelemente können weitere definierende Vorkommen sichtbar gemacht werden (qualifizierte Bezeichner - this.x, Importe, x.y, etc.). Definierende Vorkommen werden durch Schachtelung von Gültigkeitsebenen versteckt.


Die Identifizierung von Bezeichnern

ordnet jedem angewandtem Vorkommen ein definierendes Vorkommen zu. (Bei Überladung kann und soll sich ein angewandtes Vorkommen auf mehrere definierende Vorkommen beziehen; die Auflösung dieser Mehrdeutigkeit wird durch Kontextanalyse und meistens über Typinformationen realisiert). Die Implementierung wird mittels einer Symboltabelle gemacht, d.h. durch eine Abbildung Id \rightarrow Info. Info ist die korrekte Deklaration für dieses angewandte Vorkommen. Um diese Abbildung zu implementieren gibt es drei Möglichkeiten:

  • Verweis auf den Knoten der Deklaration, also zum definierenden Vorkommen im AST
  • Referenz auf den Symboltabelleneintrag der Deklaration
  • direkt die Information aus der Deklaration kopieren.


Operationen der Symboltabelle


Implementierung

naiv:

Die Symboltabelle ist ein Stack von Blocktabellen, die Tabelle des aktuellen Blocks ist das erste Element. Eine Blocktabelle enthält Einträge für alle Bezeichner deren definierendes Vorkommen im entsprechenden Block liegt.

besser:

  • eine globale Suchtabelle (Hashtable)
  • Eintrag zu Bezeichner ist Stack von Deklarationen
  • Stack von Mengen von Bezeichnern; eine Menge pro Block
    • outer_block: push \emptyset
    • exit_block: entferne den oberen Stackeintrag in der Tabelle zu jedem Bezeichner in der Menge auf der Stackspitze (pop).
Code: Scoping
type (id',decl') Symtab = {
 table : (id',decl') Hashtable.t; 
 stack : 'id list list ref 
}  

let create () = {
 table = Hashtable.create 101;
 Stack = ref left [right]
}  

let enter_block st = 
  st.block := left [right] :: !st.block 

let enter_id st id decl = 
  let_ = Hashtable.add st.table id decl in  (* versteckt unter anderem den alten Eintrag *) 
    let vars :: Stacks = lst,Stack in 
      st.block := (id::vars)::Stacks 

let search_id st id = 
  Hashtable.find st.table id 

let exit_block st = 
  let vars::blocks = !st.block in 
    let_ = st.block := blocks in 
      List.iter(Hashtable.remove st.table) vars 


Auflösung der Überladung

Problem: Zu einem Attribut eines Bezeichners gibt es mehrere sichtbare definierende Vorkommen. Dies entsteht durch "Überladung" von Bezeichnern, meist Methoden- bzw. Funktionsnamen, Infixoperatoren, aber teilweise auch Variablen mit gleichem Namen aber unterschiedlichen Typen. Durch geeignete Auswahl der Identifikationskriterien lässt sich eine Überladung auflösen.

Beispiel: Auswahl bei Ada
  • Anzahl von Operanden
  • Typen der Operanden
  • Typ des Rückgabewerts
  • (Typkonvertierung (cast) macht weiter Schwierigkeiten)

Ein definierendes Vorkommen überdeckt - bei Überladung - nicht alle gültigen sondern nur die äquivalenten definierendes Vorkommen (bezüglich der Auswahl).


Algorithmus zur Auflösung von Überladungen in Ada

\begin{array}{ll}
O \rightarrow \odot & Operator \\
N \rightarrow \odot ( \ N_1 \ldots N_n ) & Operatoranwendung \\
\end{array}


\begin{array}{lcl}
Syn(0) & := & Symbol \\
Inh(N) & := & par\_typ, \ erwarteter \ Ergebnistyp \ (Menge) \\
Syn(N) & := & res\_typ, \ berechneter \ Ergebnistyp \ (Menge \ von \ moegl. \ Ergebnistypen) \\
& & ops\_bn, \ Menge \ von \ definierenden \ Vorkommen \ der \ gleichen \ Operatorbezeichner \\
& & ops\_td, \ Menge \ von \ definierenden \ Vorkommen \ der \ gleichen \ Operatorbezeichner \\
& & op \subseteq Menge \ der \ definierenden \ Vorkommen \ eines \ Operators \\
& & arity(op), \ Die \ Stelligkeit \ eines \ Operators \\
par\_typ(op,i) & := & deklarierter \ Typ \ des \ i-ten \ Parameters \\
ret\_typ(op) & := & deklarierter \ Ergebnisstyp \ von \ op \\
\end{array}

Code: Pseudocode

Diese Sektion ist nicht fertig! Der Algorithmus scheint fehlerhaft, bitte ignorieren sie diese Sektion


\begin{array}{l}
N \rightarrow 0 \ N_1 \ldots N_k \\
let \ ops = \{ op | op.symbol = 0.symbol, op \ sichtbar, arity(op)=k \} \ in \\
 \ \ N.ops\_bn = \{ op \subseteq ops | {\forall}_i \ par.typ(op,i) \in N_{i}.res\_typ \} \ in \\
 \ \ \ \ N.res\_typ = \{ res.typ(op)|op \in N.ops.bn \} \\
 \ \ \ \ N.ops\_td = \{ op \in N.ops.bn | res\_typ (op) \ N.par\_typ res.typ(op)|op \in N.ops.bn \} \\
\end{array}

Datei:Bsp algo scoping.png
Beispiel zur Auflösung von Überladungen


Scanner und Parser verbinden

Über all diese Überlegungen zum Parsen haben wir bisher die Frage nach der lexikalischen Analyse gänzlich ignoriert - sie spielt keine Rolle bei den theoretischen Grundlagen des Parsen. Natürlich ist es augenscheinlich notwendig das wir die beiden Aspekte auf irgendeine Art und Weise verbinden müssen. Die Trennung zwischen lexikalischer und syntaktischer Analyse ist kaum mehr als die Trennlinie, die bei Grammatiken die regulären von den nicht-regulären unterscheidet. Das bedeutet jedoch, daß die Terminalzeichen des nicht-regulären Teils (der Teil der von der syntaktischen Analyse behandelt wird), die die Tokens der lexikalischen Analyse darstellen, in Wirklichkeit Nichtterminalzeichen der originären, vollständigen Grammatik sind. Als solche benötigen sie Attribute.
Jetzt wird klar, daß die Attribute aus der Phase der lexikalischen Analyse tatsächlich die selbe Rolle wie die Attributsinstanzen der syntaktischen Analyse spielen. Es sind nun lediglich kleinere Veränderungen des Attribute evaluierenden Parsers notwendig, um die Tatsache zu berücksichtigen, daß seine Eingabe keine Folge aus Terminalzeichen ξ ist, sondern vielmehr eine Folge von Terminalzeichen/Attribut-Paaren w. In der folgenden Spezifiktion entnimmt {\pi}_{1}^{*}:{(T \times X)}^{*} \rightarrow T^{*} nur die Terminalzeichen aus einer Sequenz von Terminalzeichen/Attribut-Paaren.

\left [ q \right ] (\omega, c_1, \ldots , c_{nactive(q)}, v_1, \ldots , v_{nactive(q)} :=


\begin{array}{ll}
\bold{letrec} & c_0 (X,v_0,\omega) = \bold{let} \ q' = goto(q,X) \ \bold{in} \\
& \ \ \ \left [ q' \right ] (\omega, c_o, c_1, \ldots , c_{nactive(q')-1}, v_0, v_1, \ldots , v_{nactive(q')-1}) \\
\bold{in} & A \rightarrow \alpha \cdot (\rho) \in \bar q \wedge {({\pi}_1^{*}(\omega))}_{|k} = \rho \ \triangleright c_{|\alpha|} (A, f_{\langle A \rightarrow \alpha \rangle .v})(v_{|\alpha|}, \ldots , v_1), \omega) \\
| & \omega = (x,v_0) {\omega}' \wedge x \in nextterm(q) \triangleright c_0 (x,v_0,{\omega}')
\end{array}

abstrakte Syntax

Es ist möglich den kompletten Kompilierungsprozess durch die Attribuierungsregeln einer attribuierten Grammatik ablaufen zu lassen. Jedoch werden die Attributevaluierungsregeln während der Evaluierung durch den Parser so weit beschränkt das dieser Vorgang sehr schwierig ist: Der Compiler müsste die Konstrukte in der selben Reihenfolge übersetzen in der sie eingelesen werden - das würde viele Optimierungen unmöglich machen. Außerdem benötigen bestimmte Sprachen mehrere Durchläufe durch das Programm um z.B. Bezeichner aufzulösen. Das kann nicht einfach mit einer on-the-fly Attributsevaluierung gemacht werden.

Konsequenterweise ist es besser die Attribuierungsregeln einfach eine Repräsentation eines Ableitungsbaum konstruieren zu lassen. Jedoch ist die direkte Repräsentation eines Ableitungsbaum nicht die geeignetste für die Zwecke des Compilers. Betrachten wir die Syntax einer Teilmenge von Caml namens Mini-Caml:

Code: Mini-Caml Syntax
<exp> ::= <literal>
   | <ident>
   | ( <operator-name> )
   | ( <exp> )
   | <exp> <exp>
   | <prefix-symbol> <exp>
   | <exp> <infix-symbol> <exp>
   | if <exp> then <exp> else <exp>
   | <exp> or <exp>
   | <exp> & <exp>
   | <exp> ; <exp>
   | function <ident> -> <exp>
   | raise <exp>
   | try <exp> with <ident> -> <exp>
   | let rec? <let-binding> <more-bindings>* in <exp>
   | [ <sequence> ]

<literal> ::= ( ) | <integer-literal> | <character-literal> | <string-literal>
<operator-name> ::== <infix-symbol> | <prefix-symbol>
<sequence> ::= <empty> | <exp> <sequence-rest>*
<sequence-rest> ::= ; <exp>
<let-binding> ::= <ident>+ = <exp>
<more-bindings> ::= and <let-binding>
<definition> ::= let rec? <let-binding> <more-bindings>*
<program> ::= <definition>*

Die Syntax beinhaltet viel "Interpunktion": Klammern, Pfeile, Infixseparatoren wie in oder then die in Ableitungsbaum einfach "tote Blätter" werden: Selbst wenn wir sie auslassen könnten sie leicht automatisch wieder hergestellt werden. Außerdem erlaubt die Syntax Infix-Ausdrücke die eigentlich nur ein anderer Weg sind, binäre Funktionen zu schreiben. Außerhalb der Grammatik gibt es keine Notwendigkeit zwischen der Anwendung von Infixen und Präfixen zu unterscheiden.
Daher verwenden Compiler normalerweise eine abstraktere Repräsentation eines Ableitungsbaums die abtstrakter Syntaxbaum (AST) genannt wird.

Diese beinhaltet immer noch alle notwendigen Informationen um die Bedeutung eines Programms herleiten zu können läßt aber alle unnötigen Details aus. Es ist möglich aus der abstrakten Syntax des Originalprogramm ein äquivalentes Programm zu konstruieren. Deswegen hat die abstrakte Syntax ähnliche Eigenschaften wie die Token/Attribut-Paare, die der Scanner herstellt. Als Faustregel kann man sagen das eine Produktion in der Grammatik zu einem Konstrukt in der abstrakten Syntax korrespondiert. Die Einzelheiten hängen jedoch von der jeweiligen Grammatik und der jeweiligen Parser-Methode, die die Struktur der Grammatik beeinflussen kann, ab.

Mit Hilfe der abtstrakten Syntaxbäume kann nun der volle Formalismus der attribuierten Grammatiken hergestellt werden. Die wichtigste Erkenntnis ist, das Attribuierungen nicht auf Ableitungsbäume beschränkt sind sondern für alle Arten von Baumstrukturen angewendet werden können, im speziellen für abtstrakte Syntaxbäume. Daher kann eine attribuierte Grammatik in geeigneter und erklärender (conveniently and declaratively ) Weise alle Arten von semantischen Analysen auf abtstrakten Syntaxbäume spezifizieren. In diesem Fall ist die Beschränkung alle Attributsdekorationen in einem Durchlauf (z.B. während des Parsens) zu erzeugen nicht mehr vorhanden; fortgeschrittene Multi-Pass-Techniken für das Berechnen der Attributsdekoration existieren und können angewandt werden.

Im Falle von Mini-Caml (was schon einige der syntaktischen Frivolitäten der vollen Caml-Sprache ausläßt) kann die abstrakte Syntax sogar noch einfacher als die konkrete Syntax sein. Die nächste Sektion zeigt eine Caml-Typ Deklaration für eine solche abstrakte Syntax:

Code: abstrakte Mini-Caml Syntax
type 'ident syntax = 
     Nil
   | Int of int
   | String of string
   | Char of char
   | Ident of 'ident
   | Builtin of 'ident
   | Apply of 'ident syntax * 'ident syntax
   | If of 'ident syntax * 'ident syntax * 'ident syntax
   | Sequence of 'ident syntax * 'ident syntax
   | Function of 'ident * 'ident syntax
   | Raise of 'ident syntax
   | Try of 'ident syntax * 'ident * 'ident syntax 
   | Let of 'ident binding * 'ident syntax
   | Letrec of 'ident binding list * 'ident syntax
 and 'ident binding = 'ident * 'ident syntax

type 'ident definition = 'ident binding list

type 'ident program = 'ident definition list 

Natürlich gibt es einige seltsame Konstrukte die nach einer Erklärung verlangen:

  • Die Typdeklaration ist mit dem Typ der Bezeichner parametrisiert. Das macht es später einfacher automatisch generierte Bezeichner modulare Systeme und ähnliches zu behandeln.
  • Sie unterscheidet zwischen "normalen" (Ident) und eingebauten (Builtin) Bezeichnern, wobei sich die eingebauten auf eingebaute Operationen wie primitive arithemtische Operationen, Speicheralloziierung und ähnliches beziehen. Obwohl der Parser keine Builtin-Knoten produzieren wird, kann eine spätere Analyse herausfinden, welche Ident-Knoten sich tatsächlich auf eingebaute Operationen beziehen.
  • Sie unterscheidet zwischen let und letrec. Es wird klarwerden das beide Konstrukte in einer späteren Phase des Kompilierens genügend unterschiedliche Behandlung benötigen um diese Unterscheidung zu rechtfertigen.

Zwischensprachen

Kapitel auf einer Seite lesen

Zwischensprachen werden benutzt, um die Ergebnisse des Frontend (lexikalische, syntaktische und semantische Analyse) mit dem Backend (Codeerzeugung- und Optimierung) zu verbinden. Sie werden eingesetzt, weil man hierdurch die Anzahl der zu schreibenden Programme deutlich verringern kann: Hat man m Quell- und n Zielsprachen, so benötigt man mit Zwischensprache lediglich m + n Programme zur Uebersetzung. Wir suchen hierfür eine interne Repräsentation eines Programmes, für die diese Kriterien erfüllt sind:

  • einfach zu analysieren
  • einfach zu transformieren
  • einfache Codeerzeugung (Elemente der Repräsentation sollen wenigen Maschinenbefehlen entsprechen)
  • eindeutig festgelegte Bedeutung

Die Vorlesung überspringt hier eine Kapitel aus dem ursprünglichen Skript, den Lambda-Kalkül. Es entsteht dadurch ein bisschen Verwirrung, was den Inhalt angeht, aber im Grunde bezweckt dieses Kapitel die Vereinfachung des Quellcodes nach den oben beschriebenen Kriterien. Zu diesem Zweck entwerfen wir eine Reihe von Interpretern: zuerst einen naiven Interpreter, der dann zu einem Interpreter mit continuation-passing style (CPS) ausgebaut wird, um daraufhin Lambda-Lifting (basierend auf dem Lambda-Kalkül) und weitere Verfahren zu benutzen, die uns schrittweise dem Maschinencode näherbringen.

Naiver Interpreter

Wir beginnen mit einer Struktur zur Buchführung über die Bezeichner, environment und zugehörigen Operationen. Dies werden als Funktionen festgelegt, damit wir die Struktur in Zukunft optimieren können, ohne den restlichen Code ändern zu müssen:

<div style="padding-left:4px;" id="Code: environment">Code: environment
type expr =
    Int of int
  | Builtin of ident * expr list
  | If of expr * expr * expr
  | Ident of ident
  | Let of ident * expr * expr
  | Function of ident * ident list * expr
  | Apply of expr * expr list
  | Raise of expr
  | Try of expr * ident * expr
  | Var of ident * expr * expr
  | Assign of ident * expr

(* environment manipulation - the environment is a list of identifier-value pairs. *)
type 'value env =
  (string * 'value) list
let extend env x v =
  ((x,v)::env)
let rec extends env xs vs =
  match (xs, vs) with
      (x::xs, v::vs) -> extends (extend env x v) xs vs
    | ([], []) -> env
let lookup =
  List.assoc

</div>

Als nächstes folgt der naive Interpreter eval selbst:

Code: naiver Interpreter
(* begin with a few definitions for values and exceptions *)

type value =
    VInt of int
  | VFun of (value list -> value)
  | VRef of value ref
  | VUnit of unit

let exInt (VInt i) = i
let exFun (VFun f) = f
let exRef (VRef r) = r
let exUnit (VUnit u) = u

exception VException of value

(* eval : Syntax.t −> value env −> value *)
let rec eval e env =
  match e with
      Int (i) -> VInt (i)
    | Builtin (f, es) -> (
        match (f, evals es env) with
            ("+", [v1; v2]) -> VInt (exInt v1 + exInt v2))
    | If (e1, e2, e3) -> 
        if exInt (eval e1 env) != 0
        then eval e2 env
        else eval e3 env
    | Ident (x) -> lookup x env
    | Let (x, e1, e2) -> eval e2 (extend env x (eval e1 env))
    | Function (x0, xs, e) -> VFun (function vs -> eval e (extends env xs vs))
    | Apply (e0, es) -> (exFun (eval e0 env)) (evals es env)
    | Raise (e) -> raise (VException (eval e env))
    | Try (e0, x, e1) -> (
        try eval e0 env with
          VException v -> eval e1 (extend env x v))
    | Var (x, e1, e2) -> eval e2 (extend env x (VRef (ref (eval e1 env))))
    | Assign (x, e) -> VUnit ((exRef (lookup x env)) := eval e env)

and evals es env =
  match es with
      [] -> []
    | e :: es -> eval e env :: evals es env

Continuation-Passing Style

Wie man im naiven Interpreter bei Apply(e1, e2) sehen kann, ist die Reihenfolge der Auswertung von Argumenten nicht festgelegt, gegebenenfalls wird e2 gar gar nicht ausgeführt. Das nennt man auch das Sequenzierungsproblem. Damit die Auswertung der Argumente sichergestellt wird, übergeben wir nun nur noch Werte als Parameter. Erreicht wird das durch Transformation des Programms in den Continuation-Passing Style (CPS). Ein Ausdruck in einem CPS Programm liefert kein Ergebnis, sondern übergibt das Ergebnis an eine Continuation, das heisst eine Funktion, die den Kontext des Ausdrucks (den Rest des Programms) repräsentiert.

Als Illustration mag dieser Vergleich der Fakultätsfunktion in "direktem Stil" und CPS dienen:

Beispiel: Fakultät
(* direct style *)
let rec fac n = 
  if n = 0 then 1 else n * fac (n - 1)

(* CPS, unter der Annahme, dass
     =' (x, y) c
     *' (x, y) c
     -' (x, y) c
   die CPS Versionen von =, *, - sind. *)
let rec fac' n c =
  ='(n, 0) (function b ->
    if b then 
      c 1
    else -'(n, 1) (function n' ->
      fac' n' (function r' ->
        *'(n, r') (function r -> c r))))

Wie man sieht, sind alle Argumente Werte, die Reihenfolge der Auswertung ist festgelegt, Funktionsaufrufe werden zu Sprüngen und primitve Argumente können teilweise als Maschieneninstruktionen gelesen werden. Es gibt aber auch ein paar Nachtteile: Eine wirkliche Festlegung auf primitive Operatoren/Maschienenbefehle ist hier zu früh (weil es je nach Architektur unterschiedliche Spezialbefehle gibt, z.B. MULT/ADD bei der Power-Architektur). Auch müssen die Continuations unterschiedlich repräsentiert werden:

  • (function n' -> ...) Vorwärtsschalten des Program Counters (PC)
  • (function r' -> ...) ? (jedenfalls nicht Vorwärtsschalten des PC)

Für unsere Zwecke reicht deshalb eine schwächere Form, bei der alle Argumente primitiv auswertbar sind:

Beispiel: Fakultät 2
let rec fac'' n c =
  if n = 0 then 
    c 1
  else
    fac'' (n-1) (function r' -> c (n * r'))

Daraus folgend konstruieren wir jetzt einen naiven Interpreter mit CPS:

Code: CPS-Interpreter
(* as before we start with definitions - note the changed line for VFun *)
type value =
    VInt of int
  | VFun of (value list -> (value -> value) -> value)
  | VRef of value ref
  | VUnit of unit

let exInt (VInt i) = i
let exFun (VFun f) = f
let exRef (VRef r) = r
let exUnit (VUnit u) = u

exception VException of value

(* eval : Syntax.t −> value env −> (value −> value) −> value *)
let rec eval e env c =
  match e with
      Int (i) -> c (VInt (i))
    | Builtin (f, es) ->
        evals es env (function vs ->
          (match (f, vs) with
            ("+", [v1; v2]) -> c (VInt (exInt v1 + exInt v2))))
    | If (e1, e2, e3) ->
        eval e1 env (function v1 ->
          if exInt v1 != 0 then 
            eval e2 env c
          else 
            eval e3 env c)
    | Ident (x) -> c (lookup x env)
    | Let (x, e1, e2) ->
        eval e1 env (function v1 ->
          eval e2 (extend env x v1) c)
    | Function (x0, xs, e) ->
        c (VFun (function vs ->
          eval e (extends env xs vs)))
    | Apply (e0, es) ->
        eval e0 env (function v0 ->
          evals es env (function vs -> (exFun v0) vs c))
    | Raise (e) ->
        eval e env (function v -> raise (VException v))
    | Try (e0, x, e1) -> (*not correct*)
       (try eval e0 env c with
         VException v -> eval e1 (extend env x v) c)
    | Var (x, e1, e2) ->
        eval e1 env (function v1 ->
          eval e2 (extend env x (VRef (ref v1))) c)
    | Assign (x, e) ->
        eval e env (function v ->
          (exRef (lookup x env)) := v ; c (VUnit ()))

and evals es env c =
  match es with
      [] -> c []
    | e :: es ->
        eval e env (function v ->
          evals es env (function vs -> c (v :: vs)))

Wie man sieht, haben wir das Sequenzierungsproblem damit gelöst, allerdings mit dem Nachteil, für CPS sehr viele Funktionen definiert zu haben.

Exceptions

Im ursprünglichen Interpreter werden die Exceptions mit Hilfe der interpretierenden Sprache behandelt:

Code: naiver Interpreter Auszug
exception VException of value

let rec eval e env =
  match e with
      ...
    | Raise (e) -> raise (VException (eval e env))
    | Try (e0, x, e1) -> (
        try eval e0 env with
          VException v -> eval e1 (extend env x v))

Wie man am folgenden Beispiel sieht, kann man aus der ursprünglichen Version eine Transition nach CPS vornehmen, die keine Exceptions in der interpretierenden Sprache benötigt. Wir sehen uns dazu product, eine optimierte Version mit Exception und dann die CPS Version an:

<div style="padding-left:4px;" id="Beispiel: product">Beispiel: product
(* normal product *)
let rec product xs = 
  match xs with
      [] -> 1
    | x::xs' -> x * product xs'

(* optimized *)
let product xs = 
  let rec pr xs = 
    match xs with
        [] -> 1
      | x::xs' -> 
          if x = 0 then 
            raise Null
          else
            x * xs'
  in
  try pr with Null -> 0

(* CPS version *)
let product xs c = 
  let rec pr xs c'
    match xs with
        [] -> c' 1
      | x::xs' -> 
          if x = 0 then 
            c 0
          else 
            pr xs (function r -> c' (x * r))
  in
  pr xs c

</div>

Man beachte, dass CPS hier etwas waghalsig benutzt wird: Die Verwendung von c im then Teil sollte eigentlich nicht vorkommen, da nur die innerste Continuation zur Benutzung gedacht ist. Man kann deshalb eine weitere Version benutzen, die mit einer zusätzlichen Exception-Continuation die Exceptions systematisch behandelt:

Beispiel: Exception-Continuation
let product xs ex c = 
  let rec pr xs ex c = 
    match xs with
        [] -> c 1
      | x::xs' -> 
          if x = 0 then
            ex Null (* = raise *)
          else
            pr xs ex (function r -> c (x * r))
  in
  pr xs (function Null -> c 0) c
        (*      = try       *)

Closures

Während sich der letzte Abschnitt (auch) mit dem Umwandeln von Operationen in maschinennahe Instruktionen beschäftigt hat, geht es hier und im nächsten Abschnitt um die Behandlung der Funktionsaufrufe. Unser Ziel ist es, ein Programm so zu transformieren, dass es leicht in Maschinencode umgewandelt wird. Wenn wir uns noch einmal den naiven Interpreter ins Gedächtnis rufen, sehen wir, dass er zwar funktioniert, aber die Behandlung von Funktionen der interpretierenden Sprache überlässt:

Beispiel: naiver Interpreter Auszug
type value =
    VInt of int
  | VFun of (value list -> value)

let exInt (VInt i) = i
let exFun (VFun f) = f
...

let rec eval e env =
  match e with
      ...
    | Function (x0, xs, e) -> VFun (function vs -> eval e (extends env xs vs))
    | Apply (e0, es) -> (exFun (eval e0 env)) (evals es env)

Um die Funktionsbehandlung abstrahieren, führen wir deshalb Closures ein, die die Funktionsumsetzung übernehmen:

Code: Interpreter mit Closures
type ident = string

type value =
    VInt of int
  | VClosure of ident list * expr * value env

let rec eval e env =
  match e with
      ...
    | Function (x0, xs, e) -> VClosure(xs, e, env) 
    | Apply (e0, es) -> 
        let VClosure (xs', e', env') = eval e0 env in
        let vs = evals es env in
        eval e' (extends env' xs' vs)

Lambda-Lifting

Immer noch ist es unser Ziel, ein Programm so zu transformieren, dass es leicht in Maschinencode umgewandelt werden kann. Geschachtelte Funktionen sind diesem Ziel aber abträglich. Deshalb soll sich dieser Abschnitt mit der Aufhebung von Schachtelung beschäftigen: Wir wollen alle Funktionen zu top-level Funktionen machen. Das Verfahren hierzu nennt sich Lambda-Lifting. Wir beginnen mit einer Illustration des Problems und fahren mit Methoden zur Behebung fort.

Bemerkung: Lambda-Kalkül

Wie schon zu Beginn des Kapitels bemerkt, fehlt uns hier eigentlich der Lambda-Kalkül. Es reicht für uns hier zu wissen, dass z.B. die "addiere Zwei" Funktion f(x) = x + 2 im Lambda-Kalkül so ausgedrückt wird: λx. 2 + x, und die Anwendung f(3) so: (λx. 2 + x) 3.

Beispiel: top-level
let i = 5 in
letrec f = λx. f (i + i) in
f (i * i)

(* naive, errorenous top-level transformation: 
   obviously i would not be defined in the first line *)
letrec f = λx. f (i + i) in
let i = 5 in 
  f (i * i)

(* use i as parameter *)
letrec f = λi. λx. f i (i + i) in
let i = 5 in
  f i (i * i)

(* real transformation: first introduce variables that are free within the function *)
let i = 5 in
letrec f = λi. λx. f i (i + i) in
  f i (i * i)

Diese einfachen Umformungen reichen aber in der Regel nicht aus, um alle Probleme zu beseitigen, da Funktionen und ihre Parameter verschränkt sein können:

Beispiel: verschränkte Funktionen
let a = ... in
let b = ... in 
letrec f = λx. ... a ... g x
       g = λx. ... b ... f x
in 
f 42

(* transformed once by the previous mechanism this yields: *)
let a = ... in
let b = ... in 
letrec f = λa. λx. ... a ... g b x
       g = λb. λx. ... b ... f a x
in 
f a 42

(* as we can see, this is wrong: the functions have gotten their own free variables 
   as parameters, but at the same time new parameters are introduced for other 
   functions, creating new free variables within the first function. An additional 
   pass of the transformation yields a satisfying result: *)
let a = ... in
let b = ... in 
letrec f = λb. λa. λx. ... a ... g a b x
       g = λa. λb. λx. ... b ... f b a x
in 
f b a 42

Mit dem mehrfachen Transformieren, wie es im obigen Beispiel gezeigt wird, kann man also die Funktionen von freien Variablen befreien. Allerdings hat dieser naive, sukzessive Algorithmus kubische Laufzeit. Tatsächlich verwendet man deshalb folgenden Algorithmus:

Algorithmus

  • Vorbereitungsschritt:
    • Benenne alle lokalen Definitionen so um, dass die Bezeichner programmweit eindeutig sind.
    • Alle Ausdrucksfunktionen erhalten einen eindeutigen Namen: function x -> e wird zu letrec f = function x -> e in f.
  • Durchlaufe rekursiv das Programm mit Parameter F, der Menge der bereits bekannten, definierten Funktionen (zu Beginn ist F leer, beziehungsweise enthält alle vordefinierten Funktionen):
Ablauf: Lambda-Lifting

Angenommen das Programm hat dieses Format:

\begin{array}{lrl}
letrec & f_1 & = \lambda{x. e_1} \\
       &    & \vdots \\
       & f_n & = \lambda{x. e_n} \\
in\ e
\end{array}

Dann sei fvx.e) die Menge der freien Variablen in λx.e und \lbrace\overline{y}\rbrace = \bigcup_{1 \leqslant i \leqslant n} fv(\lambda{x}. e_i) \ F' die Menge der freien Variablen in den "top-most" Funktionen, wobei F' = F \cup \lbrace f_1, \ldots, f_n \rbrace (\langle\overline{y}\rangle wird hier als Parametervektor benutzt). Dann wird das Programm folgendermassen umgeformt, wobei $ die normale Funktionsanwendung bezeichnet (für später markiert):

\begin{array}{lrl}
letrec & f_1 & = \lambda^$\langle\overline{y}\rangle. \lambda{x}. e_1[f_i \mapsto f_i$\langle\overline{y}\rangle]\\
       &     & \vdots \\
       & f_n & = \lambda^$\langle\overline{y}\rangle. \lambda{x}. e_n[f_i \mapsto f_i$\langle\overline{y}\rangle]\\
in\ e [f_i \mapsto f_i$\langle\overline{y}\rangle]
\end{array}

Nach diesem Durchlauf verarbeitet man dann e, e_1, \ldots, e_n rekursiv mit F = F'. Ausserdem nimmt man vorher eine Aufspaltung der letrecs in stark verbundene Komponenten vor, da ansonsten zu viele Variablen in \langle\overline{y}\rangle sind, was ineffizient ist.

Der Weg zum Maschinencode

In diesem Abschnitt beschäftigen wir uns mit der weiteren Umformung des Codes, so dass er besser in Maschinencode gewandelt werden kann. Dazu werden drei Zwischenschritte vorgenommen:

  1. Alle Zwischenergebnisse werden benannt.
  2. Die Reihenfolge wird explizit gemacht.
  3. Der Kontrollfluss wird explizit gemacht.

Wir beginnen mit der Programmrepräsentation, Programme sind jetzt rekursive Programmschemata:

Definition: Programmschema

\begin{array}{lcl}
\langle\mbox{schema}\rangle & ::= & \langle\mbox{def}\rangle^* \langle\mbox{exp}'\rangle \\
\langle\mbox{def}\rangle & ::= & \langle\mbox{fname}\rangle = \lambda^$\langle\mbox{var}\rangle. \lambda\langle\mbox{var}\rangle. \langle\mbox{exp}'\rangle \\
\langle\mbox{exp}'\rangle & ::= & \langle\mbox{var}\rangle \\
 & | & \langle\mbox{fname}\rangle$\langle\mbox{exp}'\rangle \\
 & | & \langle\mbox{const}\rangle \\
 & | & \langle\mbox{pure}\rangle \langle\mbox{exp}'\rangle \ldots \langle\mbox{exp}'\rangle \\
 & | & \langle\mbox{oper}\rangle \langle\mbox{exp}'\rangle \ldots \langle\mbox{exp}'\rangle \\
 & | & (\langle\mbox{exp}'\rangle \langle\mbox{exp}'\rangle) \\
 & | & \left \langle \langle\mbox{exp}'\rangle \ldots \langle\mbox{exp}'\rangle \right \rangle \\
 & | & let\ \langle\mbox{var}\rangle = \langle\mbox{exp}'\rangle\ in\ \langle\mbox{exp}'\rangle \\
 & | & let\ \left \langle \langle\mbox{var}\rangle \ldots \langle\mbox{var}\rangle \right \rangle = \langle\mbox{exp}'\rangle\ in\ \langle\mbox{exp}'\rangle \\
 & | & if\ \langle\mbox{exp}'\rangle\ then\ \langle\mbox{exp}'\rangle\ else\ \langle\mbox{exp}'\rangle
\end{array}


Man beachte, dass

  • die Deklaration von def Funktionen behandelt, die vom Lambda-Lifting kommen,
  • im let Guard Variablen gebunden und vor allem ihre Reihenfolge festgelegt wird
  • der Anfang des let Guard (let <var> = <exp'>) als Header bezeichnet wird
  • <pure> Operatoren ohne Seiteneffekte beschreibt (zum Beispiel arithmetische/logische Opertatoren)
  • <oper> zum Beispiel für Interrupts genutzt wird.
Verfahren: Entfernen der Vektoren vom Lambda-Lifting

Man entfernt die Vektoren vom Lambda-Lifting mit einer einfachen Umformung:

\lambda^$\langle y_1,\ldots, y_m\rangle.\ \lambda{x}.\ e \Rightarrow \lambda^$z.\ \lambda x.\ let\ \langle y_1 \ldots y_m \rangle = z\ in\ e

Notation: Schemata

In den folgenden Abschnitten gilt diese Notation: x, x_i \in \langle \mbox{var}\rangle, f \in \langle\mbox{fname}\rangle, p \in \langle \mbox{pure} \rangle, o \in \langle\mbox{oper}\rangle, e, e' ,e_i \in \langle\mbox{exp}'\rangle, c \in \langle\mbox{const}\rangle.

Wir kommen nun zum ersten Zwischenschritt, dem Benennen der Zwischenergebnisse:

Benennen der Zwischenergebnisse

Unser Ziel beim Benennen der Zwischenergebnisse ist es, allen Werten, die in Registern gespeichert werden müssen, einen Namen zu geben. Wir bedienen uns dabei einer Transformationsfunktion \mathcal{W}:

Definition: \mathcal{W}

\mathcal{W}^b[e] = \begin{cases}let\ x = e\ in\ x & b = true \\ e & b = false \end{cases}

Der Wert b wird hierbei aus Kontextinformationen gewonnen, die vier Werte annehmen können:

  • v - Kontext verlangt Variable
  • f - Kontext verlangt Funktion
  • l - Kontext ist let-Header
  • t - Kontext hat keine Anforderungen

Mit Hilfe dieser Funktion können wir nun gegebene Programmabschnitte transformieren:

Definition: Tranformation für explizite Zwischenschritte

q \in \mbox{Kontextinformationen}

\begin{array}{ll}
[v]^q & = v \\
{}[f $ e]^q & = \mathcal{W}^{q \neq f} [f $ [e]^v] \\
{}[c]^q & = \mathcal{W}^{q = v} [c] \\
{}[p\ e_1 \ldots e_m]^q & = \mathcal{W}^{q = v} [p\ [e_1]^t \ldots [e_m]^t] \\
{}[o\ e_1 \ldots e_m]^q & = \mathcal{W}^{q \neq l} [o\ [e_1]^v \ldots [e_m]^v] \\
{}[(e\ e')]^q & = \mathcal{W}^{q \neq l} [([e]^f\ [e']^v)] \\
{}[\langle e_1 \ldots e_m \rangle]^q & = \mathcal{W}^{q \neq l} [\langle [e_1]^v \ldots [e_m]^v\rangle] \\
{}[let\ x = e\ in\ e']^q & = let\ x = [e]^l\ in\ [e']^q \\
{}[let\ \langle x_1 \ldots x_m \rangle = e\ in\ e']^q & = let\ \langle x_1 \ldots x_m \rangle = [e]^l\ in\ [e']^q \\
{}[if\ e_1\ then\ e_2\ else\ e_3]^q & = \mathcal{W}^{q \neq l} [if\ [e_1]^v\ then\ [e_2]^v\ else\ [e_3]^v]

\end{array}

Um die Idee zu verdeutlichen, folgt ein Beispiel, in dem die Funktion power aus ihrer Originalfassung bis zu benannten Zwischenschritten umgeformt wird.

Beispiel: power

Originalprogramm:

power\ x =

letrec\ g = \lambda{n}.\ if \ n = 0\ then\ 1\ else\ x * g (n-1)\ in
\ g


Nach Lambda-Lifting:

g = \lambda^$z.\ \lambda{n}.\ let\ \langle x \rangle = z\ in

if\ n = 0\ then\ 1\ else\ x * g$z (n-1)

power = \lambda^$z.\ \lambda{x}.\ g$\langle x \rangle


power mit benannten Zwischenergebnissen:

power = \lambda^$z.\ \lambda{x}.\ let\ x_1 = g$(let\ x_2 = \langle{x}\rangle\ in\ x_2)\ in\ x_1

g = \lambda^$w.\ \lambda{n}.\ let\ \langle x \rangle = z\ in

let\ x_2 =
if\ (let\ x_3 = (n=0)\ in\ x_3)\ then
let\ x_4 = 1\ in\ x_4
\,\!else
let\ x_5 = x * (
let\ x_6 = g$z (let\ x_7 = n-1\ in\ x_7)
in\ x_6)
in\ x_5
in\ x_2

Explizite Reihenfolge

In dem Code, der aus dem Benennen der Zwischenergebnisse folgt, finden sich sehr viele verschachtelte let Blöcke. Um die Reihenfolge der Ausführung von Termen explizit zu machen, werden diese "nach oben gezogen", oder "geplättet". Das Verfahren nennt sich let flattening, es verwendet lokale Transformationsregeln, die so lange angewendet werden, bis alle Möglichkeiten der Anwendung erschöpft sind.

Wir betrachten dazu folgende Werte:

Definition: let flattening Werte

\begin{array}{lcl}
\langle\mbox{triv}\rangle & ::= & \langle \mbox{var} \rangle \\
  &   | & \langle \mbox{fname} \rangle $ \langle \mbox{var} \rangle \\
  &   | & \langle \mbox{const} \rangle \\
  &   | & \langle \mbox{pure} \rangle \langle \mbox{triv} \rangle \ldots \langle\mbox{triv} \rangle
\end{array}

Lasse v über die Werte von \langle \mbox{triv}\rangle laufen. Mit diesen Werten können wir nun die Transformationsregeln angeben:

Definition: let flattening Transformation

Alle hier angegebenen Regeln für let\ x = e\ in \ldots können auch auf let\ \langle x_1 \ldots x_m \rangle = e\ in \ldots angewandt werden. Wir können ausserdem davon ausgehen, dass nie freie Variablen überschrieben werden, da im vorherigen Schritt jede Variable einen eindeutigen Bezeichner erhalten hat.

\begin{array}{ll}
f $(let\ x = e\ in\ e') & \mapsto let\ x = e\ in\ f $ e' \\
p\ v_1 \ldots v_i\ (let\ x = e\ in\ e')\ e_1 \ldots e_k & \mapsto let\ x = e\ in\ p\ v_1 \ldots v_i\ e'\ e_1 \ldots e_k \\
o\ v_1 \ldots v_i\ (let\ x = e\ in\ e')\ e_1 \ldots e_k & \mapsto let\ x = e\ in\ o\ v_1 \ldots v_i\ e'\ e_1 \ldots e_k \\
\langle v_1 \ldots v_i\ (let\ x = e\ in\ e')\ e_1 \ldots e_k \rangle & \mapsto let\ x = e\ in\ \langle v_1 \ldots v_i\ e'\ e_1 \ldots e_k \rangle \\
(let\ x = e_1\ in\ e_2)\ e_3 & \mapsto let\ x = e_1\ in\ (e_1\ e_2) \\
v\ (let\ x = e\ in\ e') & \mapsto let\ x = e\ in\ (v\ e') \\
let\ x_1 = (let\ x_2 = e_1\ in\ e_2)\ in\ e_3 & \mapsto let\ x_2 = e_1\ in\ let\ x_1 = e_2\ in\ e_3 \\
if\ (let\ x = e_1\ in\ e_2)\ then\ e_3\ else\ e_4 & \mapsto let\ x = e_1\ in\ if\ e_2\ then\ e_3\ else\ e_4
\end{array}

<div style="padding-left:4px;" id="Beispiel: power mit let flattening">Beispiel: power mit let flattening

power = \lambda^$t.\ \lambda x.\ let\ x_2 = \langle x \rangle\ in

let\ x_1 = g$x_2\ in
\,\!x_1

g = \lambda^$z.\ \lambda n.\ let \langle x \rangle = z\ in

let\ x_3 = (n=0)\ in
let\ x_2 =
if\ x_3\ then
let\ x_4 = 1\ in\ x_4
\,\!else
let\ x_7 = n - 1\ in
let\ x_6 = (g$z)\ x_7\ in
let\ x_5 = x * x_6\ in
\,\!x_5
\,\!x_2

</div>

Expliziter Kontrollfluss

In diesem letzten Zwischenschritt auf dem Weg zum Maschinencode wollen wir den Kontrollfluss explizit machen. Hier geht es um die Kontrolltransitionen, die am Ende von Funktionen ("return") und in Konditionsblöcken ("if-then-else") entstehen. Unser Ziel ist es, diese Kontrolltransitionen im Code explizit zu machen, und insbesondere zu zeigen, wohin wir springen, nachdem ein Block beendet wurde.

Wir beginnen dazu mit der Einführung einer neuen Programmbeschreibung, um die Unterscheidung von Ausdrücken in triviale und komplizierte (serious) zu ermöglichen.

Definition: Kontrollfluss Programmschema

\begin{array}{lcl}
\langle\mbox{triv}\rangle & ::= & \langle \mbox{var} \rangle \\
  &   | & \langle \mbox{fname} \rangle $ \langle \mbox{var} \rangle \\
  &   | & \langle \mbox{const} \rangle \\
  &   | & \langle \mbox{pure} \rangle \langle \mbox{triv} \rangle \ldots \langle\mbox{triv} \rangle \\ 
\\
\langle\mbox{hdr}\rangle & ::= & \langle \mbox{triv} \rangle \\
  &   | & \langle \mbox{oper} \rangle \langle \mbox{var}\rangle \ldots \langle\mbox{var} \rangle \\ 
  &   | & \langle \langle \mbox{var} \rangle \ldots \langle\mbox{var} \rangle \rangle \\ 
  &   | & (\langle \mbox{triv} \rangle \langle\mbox{var} \rangle) \\ 
\\
\langle\mbox{ser}\rangle & ::= & return\ \langle \mbox{var} \rangle \\
  &   | & let\ \langle \mbox{vars} \rangle = \langle \mbox{hdr}\rangle\ in\ \langle\mbox{ser} \rangle \\ 
  &   | & (\langle \mbox{triv} \rangle \langle\mbox{var} \rangle) \\ 
  &   | & if\ \langle \mbox{var} \rangle\ then\ \langle \mbox{ser}\rangle\ else\ \langle\mbox{ser} \rangle \\
  &   | & letcont\ k$\langle \mbox{vars} \rangle = \lambda\langle \mbox{var}\rangle.\ \langle\mbox{ser} \rangle\ in\ \langle\mbox{ser} \rangle \\
  &   | & yield\ k\ \langle \mbox{var} \rangle
\end{array}


\langle\mbox{equation}\rangle ::= \langle \mbox{fname} \rangle = \lambda^$\langle \mbox{var} \rangle.\ \lambda \langle \mbox{var} \rangle.\ \langle \mbox{ser} \rangle

\langle\mbox{vars}\rangle ::= \langle \langle \mbox{var} \rangle^*\rangle

Der Ausdruck letcont\ k$\langle x_1 \ldots x_n\rangle = \lambda x.\ s_1\ in\ s_2 dient dazu, eine Zieladresse für den Sprung aus einem Konditionalblock festzulegen. Die Funktion k repräsentiert dieses Ziel und wird mit dem yield Ausdruck ausgeführt. Das Verhalten dieser Transformation (Definition weiter unten) lässt sich am besten an unserem power Beispiel betrachten:

Beispiel: expliziter Kontrollfluss für power

power = \lambda^$t.\ \lambda x.\ let\ x_2 = \langle x \rangle\ in

let\ x_1 = g$x_2\ in
\,\!return\ x_1

g = \lambda^$z.\ \lambda n.\ let \langle x \rangle = z\ in

let\ x_3 = (n=0)\ in
letcont\ k$x = \lambda x_2.\ return\ x_2\ in
if\ x_3\ then
let\ x_4 = 1\ in\ yield\ k\ x_4
\,\!else
let\ x_7 = n - 1\ in
let\ x_6 = (g$z)\ x_7\ in
let\ x_5 = x * x_6\ in
\,\!yield\ k\ x_5

Wie man hier sieht, ist k nichts anderes als eine continuation, wie wir sie schon bei der Benutzung von von Continuation-Passing Style kennen gelernt haben. Um ein Programm in die oben dargestellte Form umzuwandeln, können wir die Funktion \mathcal{R}_k[\cdot] benutzen. k ist hier entweder 0, um eine top-level continuation anzuzeigen (also ein "return") oder eine von letcont eingeführte continuation.

Definition: \mathcal{R}_k[\cdot]

\begin{array}{ll}
\mathcal{R}[f = \lambda^$z.\ \lambda x.\ e] & = f = \lambda^$z.\ \lambda x.\ \mathcal{R}_0[e] \\
\mathcal{R}_k[x] & = \begin{cases} return\ x & k = 0 \\ yield\ k\ x & \mbox{sonst} \end{cases} \\
\mathcal{R}_k[let\ x_1 =\ if\ x_2\ then\ e_1\ else\ e_2\ in\ e_3] & = letcont\ k'$\langle fv(e_3) \lbrace x \rbrace \rangle = \lambda x.\ \mathcal{R}_k[e]\ in\ if\ x_2\ then\ \mathcal{R}_{k'}[e_1]\ else\ \mathcal{R}_{k'}[e_2] \\
\mathcal{R}_k[let\ \langle x_1 \ldots x_n \rangle = e \ in\ e'] & = let\ \langle x_1 \ldots x_n \rangle = e \ in\ \mathcal{R}_k[e']
\end{array}

Nachdem wir diese Definition erhalten haben, können wir noch einmal einen genaueren Blick auf das Beispiel von eben werfen: Man kann sich fragen, warum wir überhaupt die continuation \lambda x_2.\ return\ x_2 eingeführt haben. Die Ineffizienz, die daruch entsteht, dass wir erst aus dem Konditionalblock und dann aus der Funktion springen, lässt sich durch ein Ersetzen der yield\ k\ s durch returns erreichen:

<div style="padding-left:4px;" id="Beispiel: power mit effizienterem Kontrollfluss">Beispiel: power mit effizienterem Kontrollfluss

g = \lambda^$z.\ \lambda n.\ let \langle x \rangle = z\ in

let\ x_3 = (n=0)\ in
if\ x_3\ then
let\ x_4 = 1\ in\ return\ x_4
\,\!else
let\ x_7 = n - 1\ in
let\ x_6 = (g$z)\ x_7\ in
let\ x_5 = x * x_6\ in
\,\!return\ x_5

</div>

Während sich Funktionen wie power mit bisherigen Methoden gut optimieren lassen, gibt es noch einige Dinge, die man verbessern kann. Folgende Funktion down zeigt ein Optimierungsproblem mit Endrekursion auf, selbst nachdem sie mit den bei power genutzten Methoden verbessert wurde:

<div style="padding-left:4px;" id="Beispiel: down">Beispiel: down
down n = if n=0 then 1 else down (n-1)

down = \lambda^$z.\ \lambda n.

let\ x_1 = (n=0)\ in
if\ x_1\ then
let\ x_2 = 1\ in
return\ x_2
\,\!else
let\ x_3 = (n-1)\ in
let\ x_4 = down$z\ x_3\ in
return\ x_4

</div>

Das Auffangen und sofortige Zurückgeben des Aufrufs von down ist nicht besonders effizient. Es ist aber mit gegebenen Mitteln möglich, die Funktion umzuformen, so dass sie korrekte Endrekursion benutzt (hierbei springt die aufgerufene Funktion nicht zum unmittelbaren Aufrufenden zurück, sondern zum letzten Aufrufenden ohne Endrekursion):

<div style="padding-left:4px;" id="Beispiel: down mit korrekter Endrekursion">Beispiel: down mit korrekter Endrekursion

down = \lambda^$z.\ \lambda n.

let\ x_1 = (n=0)\ in
if\ x_1\ then
let\ x_2 = 1\ in
return\ x_2
\,\!else
let\ x_3 = (n-1)\ in
down$z\ x_3

</div>

Implementation

Die in den vergangenen Abschnitten behandelten Zwischenschritte auf dem Weg zum Maschinencode können in einem einzigen Durchlauf implementiert werden (one pass transformation). Der entsprechende Algorithmus wird hier in Form von OCaml Code dargestellt.

Zu Beginn stellen wir die Typen vor, die von unseren Funktionen genutzt werden:

Code: One Pass Tranformation Typen
(* source calculus *)
type exp =
    Var of ident
  | Const of ident
  | Pure of ident * exp list
  | Oper of ident * exp list
  | Vector of exp list              (* <exp, ..., exp> *)
  | App of exp * exp
  | Let of ident list * exp * exp
  | If of exp * exp * exp
  | Close of exp * exp              (* f$z *)

type definition =
  ident * ident list * ident * exp  (* f = λ^$<exp,...exp>. λx. e *)

type scheme =
  definition list * exp


(* target calculus *)
type trivial =
    TVar of ident
  | TConst of ident
  | TPure of ident * trivial list
  | TClose of ident * ident

type header =
    HTriv of trivial
  | HOper of ident * ident list
  | HApp3 of ident * ident * ident
  | HVect of ident list

type serious =
    SReturn of ident
  | SApp3 of ident * ident * ident         (* tail call to function  f$z (y) *)
  | SLet of ident list * header * serious  (* SLet(...HApp3...) - ordinary call to function *)
  | SIf of ident * serious * serious
  | SLetCont of kident * ident list * ident * serious * serious
  | SYield of kident * ident

type xDefinition =
  ident * ident * ident * serious


(* identifiers, including functions to create fresh names which aren't yet in use *)
type ident =
    StrId of string
  | NumId of string * int

type kident =
  ident

let fresh_counter =
  ref 0

let fresh_int () =
  begin
    fresh_counter := !fresh_counter + 1;
    !fresh_counter
  end

let fresh prefix =
  NumId (prefix, fresh_int ())

let freshL ps =
  List.map fresh ps

type xScheme =
  xDefinition list * serious

Wir kommen nun zu dem Hauptteil der Transformation, der Funktion toSerious, die Ausdrücke vom Typ exp in komplizierte (serious) Ausdrücke übersetzt.

<div style="padding-left:4px;" id="Code: toSerious">Code: toSerious
(* toSerious : (string * string) list -> exp -> (ident −> serious) −> serious *)
(* subst is a variable renaming, e an expression to be transformed 
   and c a continuation *)

let rec toSerious subst e c =
  match e with
      Var i ->
        c (rename i subst)
    | Const p ->
        let fr = fresh "t" in
        SLet ([fr], HTriv (TConst p), c fr)
    | Pure (p, es) ->
        toTrivialL subst es
          (fun ts ->
            let fr = fresh "t" in
            SLet ([fr], HTriv (TPure (p, ts)), c fr))
    | Oper (o, es) ->
        let fr = fresh "t" in
        toSeriousL subst es
          (fun is ->
            SLet ([fr], HOper (o, is), c fr))
    | App (Close (e1, e2), e) ->
        toSerious subst e1
          (fun x1 ->
            toSerious subst e2
              (fun x2 ->
                toSerious subst e
                  (fun x3 ->
                    let fr = fresh "t" in
                    SLet ([fr], HApp3 (x1, x2, x3), c fr))))
    | App (f, e) ->
        toSerious subst f
          (fun ff ->
            toSerious subst e
              (fun ee ->
                 let [frf; frc; fr] = freshL ["t"; "t"; "t"] in
                 SLet ([frf; frc], HTriv (TVar ff),
                 SLet ([fr], HApp3 (frf, frc, ee), c fr))))
    | Let ([x], e1, e2) ->
        toSerious subst e1
         (fun xx ->
           toSerious ((x, xx) :: subst) e2 c)
    | Let (xs, e1, e2) ->
        toSerious subst e1
          (fun xx ->
            SLet (xs, HTriv (TVar (xx)), toSerious subst e2 c))
    | If (e1, e2, e3) ->
        toSerious subst e1
          (fun x1 ->
            let [kk; xx] = freshL ["k"; "t"] in
            let body = c xx in
            SLetCont
              (kk, remove xx (free_serious body), xx, body,
               SIf
                 (x1,
                   toSerious subst e2 (fun i ® SYield (kk, i)),
                   toSerious subst e3 (fun i ® SYield (kk, i)))))
    | Close (e1, e2) ->
        toSerious subst e1
          (fun x1 ->
             toSerious subst e2
               (fun x2 ->
                 let fr = fresh "t" in
                 SLet ([fr], HTriv (TClose (x1, x2)), c fr)))
    | Vector (es) ->
        toSeriousL subst es
          (fun xs ->
            let fr = fresh "t" in
            SLet ([fr], HVect( xs), c fr))

</div>

Wir brauchen noch die Funktion toTrivial - sie hat fast die gleiche Signatur wie toSerious, der einzige Unterschied ist das Argument der continuation, die einen trivialen Term anstelle eines Bezeichners erwartet. Die Funktion inspiziert alle Fälle, in denen sie einen trivialen Term konstruieren kann und gibt sonst an toSerious weiter.

<div style="padding-left:4px;" id="Code: toTrivial">Code: toTrivial
(* toTrivial : (string * string) list -> exp -> (trivial −> serious) −> serious *)
and toTrivial subst e c =
  match e with
      Var i ->
        c (TVar (rename i subst))
    | Const p ->
        c (TConst p)
    | Pure (p, es) ->
        toTrivialL subst es
          (fun ts ->
            c (TPure (p, ts)))
    | _ ->
        toSerious subst e
          (fun xx ->
            c (TVar xx))

</div>

Die Implementationen von toTrivialL und toSeriousL sind einfach und finden sich in Trafo.pdf. Des weiteren übernimmt der oben angegebene Algorithmus noch nicht die Auflösung der Endrekursion - dazu benötigen wir eine modifizierte Version der toSerious Funktion, die nur die top-level Funktionen bearbeitet und auf Seite 91/15 in Coco-Lambda-Trans.pdf besprochen wird.

Codeerzeugung

Kapitel auf einer Seite lesen

Übersicht

Vorbereitung: Transformation des Codes

Konstantenpropagierung

Zuerst werden triviale Ausdrücke symbolisch ausgewertet.

Bei einer Variablenbindung der Form

let x = c in e

werden die Terme der Form

pure (op,[c_1;c_2;\ldots])\rightarrow TConst(\ldots)

reduziert.

let x = Vector(x_1,\ldots , x_n) in e
let \langle y_1, \ldots ,y_n \rangle = x in e'

Danach wird der nutzlose (weil z.B. nicht ausgeführte) Code entfernt. Beispielsweise ist let x = h in e nutzlos, falls h ein trivialer Ausdruck oder ein Vektor \langle \ldots \rangle mit x \notin free(e) ist.

Instruktionsauswahl

Abbildung trivialer Ausdrücke, Header und wichtiger Ausdrücke (Serious) auf Assembler-Befehle.

  • Serious: Jeder Term entspricht einer Folge von Assembler-Befehlen
  • trivialer Ausdruck: zwei verschiedene Verfahren (greedy und optimal)
  • \infty-viele Register
  • wir ignorieren vorerst das Retten von Registern über Funktionsaufrufe
Einschub: Retten von Registern (?)

m rt reg-arg0: erzeugt Spielraum bei der Registerallokation, falls in der Funktion arg0 gebraucht wird. framepointer sind eigentlich unnötig, da sp-fp konstant ist.

Konkret für MIPS-Assembler:

  • RISE, LOAD/STORE
  • 32 GPRs (General Purpose Registers)
  • Sonderregister: PC, ..., HI, LO
  • Koprozesor: FPU


siehe Instruktionsauswahl durch Patternmatching

Lebendigkeitsanalyse

siehe Lebendigkeitsanalyse

Registerzuteilung

siehe Registerallokation


Instruktionsauswahl durch Patternmatching

Idee:

  • beschreibe Instruktionen durch Fragmente von trivialen Ausdrücken
  • jede Instruktion / jedes Fragment ist mit Kosten assoziiert, beispielsweise die Anzahl der Maschinenzyklen für die Ausführung
  • bestimme dann die Abdeckung eines trivialen Ausdrucks aus Fragmenten mit minimalen Kosten.

2 grundliegende Algorithmen:

  • greedy
    • top-down-Patternmatching der Fragmente auf den Eingabeausdruck
    • Pattern dürfen überlappen
    • Pattern aufsteigend nach Kosten geordnet
      \rightarrow einfach (durch Ocaml-Patternmatching, findet aber nicht die Abdeckung mit minimalen Kosten
    • Appell: "optimal cover" (2 aufeinandertreffende Fragmente können nicht durch ein Fragment mit geringeren Kosten ersetzt werden


Beispiel: Baumgrammatik
<div> ::= VAR(id)
        | ADD (<div>,<div>)
        | CONST(id)

ADD(VAR "x", CONST "1")

Auf der rechten Seite der Grammatikregel ist auch ADD(τ, CONST(1))\xrightarrow[]{(*)}ADD(VAR(id),CONST(1)) erlaubt, mit τ als Nichtterminal und \tau\xrightarrow[]{(*)}VAR(id)

Jede Regel hat außerdem Kosten mit sich assoziiert.

Nichtterminale können folgende Attribute besitzen:

  • Liste der Instruktionen
  • simulierte Kosten
  • Wert von Konstanten
  • Registernamen

Aufgabenstellung Vorliegender Term/Ausdruck/Baum soll auf sein Startsymbol reduziert werden \rightarrow Parsen des Terms !

Verfahren:

  • Bottom-Up Durchlauf durch den Baum
  • lege an jedem Knoten die Liste der matchenden Nichtterminale ab
  • für jedes Nichtterminal ist nur das mit minimalen Kosten interessant
    Autoren-Anmerkung(Firefox): Vielleicht sollte das eigentlich "für jede Liste ist nur das NT mit den geringsten Kosten..." bedeuten. In beiden Quellen allerdings identisch so formuliert
  • Matchen (am Knoten)
    • Knotennamen müssen gleich sein
    • falls rechte Seite T(A_1, \ldots , A_n) suche A_1, \ldots , A_n in den Kinderknoten
    • falls erfolgreich:
      • berechne Kosten
      • verkette Codesequenzen
      • hänge attribuiertes Nichtterminal an den Knoten des Subjektterms an.
        Autoren-Anmerkung(Firefox): Was ist ein Subjektterm ?

Lebendigkeitsanalyse

siehe Appel: Modern Compiler Implementation in ML, Kapitel 10

Zustand des übersetzten Programs

Gesucht: Abbildung Pseudoregister \rightarrow reale Register, sodass 2 unterschiedliche Pseudoregister nur dann auf ein reales Register abgebildet werden, wenn sie nicht gleichzeitig benutzt werden.

Die Analyse wird auf einem Kontrollflußgraphen (KFG) durchgeführt:


\begin{array}{ll}
& a := 0 \\
L1: & b := a+1 \\
& c := c+b \\
& a := b \cdot 2 \\
& \mbox{if} \ (a < N) \ \mbox{goto} \ L1 \\
& \mbox{return} \ c
\end{array}

entry
I\downarrow
1a: = 0
II\downarrow
2b: = a + 1 \nwarrow
III\downarrow \ \uparrow
3c: = c + b \ \uparrow
IV\downarrow \ \uparrow
4a:=b \cdot 2 \ \uparrow
V\downarrow \ \uparrow
5a < N \nearrowVII
VI\downarrow
6\mbox{return} \ c
Kante a b c
I00x
IIx0x
III0xx
IV0xx
Vx0x
VI00x
VIIx0x

Kontrollflußgraphen

Definition

Ein Kontrollflußgraph ist ein markierter gerichteter Graph. Die Markierungen sind entry, exit, return c, a <rel_op> b, a: = e. Jede Instruktion liefert einen Knoten.

Kanten

Markierung eingehende Kanten ausgehende Kanten
entry 0 1
exit N 0
return N 0
a <rel_op> b N 2
a:=e N 1

Über die eingehende Kanten werden mit pred(n) die Nachfolgerknoten erreicht; über die ausgehenden Kanten erhält man mit succ(n) die Vorgängerknoten.

Verhalten bei Konstruktion
  • use[n]: Menge der Pseudoregister die von Knoten n verwendet werden.
  • def[n]: Pseudoregister die von Knoten n definiert (und überschrieben) werden.
Instruktion use[n] def[n]
return c {c} \emptyset
a <rel_op> b {a,b} \emptyset
a:=e Regs(e) {a}
entry,exit \emptyset \emptyset

Lebendigkeit

Definition
Definition: Lebendigkeit

Ein Pseudoregister r ist auf einer Kante des Kontrollflußgraphen lebendig falls es einen Pfad zu einem n mit r \in Use[n] gibt auf dem kein Knoten n' mit r \in Def[n] liegt.

  • r \in live\mbox{-}in[n], falls r lebendig auf einer eingehenden Kante zu n ist
  • r \in live\mbox{-}out[n], falls r auf einer ausgehenden Kante lebendig ist


\begin{array}{ll}
\rightarrow & Use[n] \subseteq live\mbox{-}in[n] \\
\rightarrow & r \in live\mbox{-}out[n] \wedge r \notin Def[n] \rightarrow r \in live\mbox{-}in[n] \\
\rightarrow & r \in live\mbox{-}in[n'], n' \in succ[n] \rightarrow r \in live\mbox{-}out[n] \\
\end{array}

Daraus ergeben sich die Datenflussgleichungen für die Lebendigkeit:

live\mbox{-}in[n]=Use[n] \cup (live\mbox{-}out[n] \setminus Def[n])

live\mbox{-}out[n]=\bigcup_{n' \in Succ(n)} live\mbox{-}in[n']

Die Lösung der Gleichungen erhalten wir durch Fixpunktiteration

Algorithmus: Fixpunktiteration für Datenflussgleichungen



\begin{array}{rl}
1 & for \ each \ n \\
2 & \ \ \ live\mbox{-}in[n] = \emptyset \\
3 & \ \ \ live\mbox{-}out[n] = \emptyset \\
4 & repeat \\
5 & \ \ \ for \ each \ n \\
6 & \ \ \ \ \ \ live\mbox{-}in'[n] = live\mbox{-}in[n] \\
7 & \ \ \ \ \ \ live\mbox{-}out'[n] = live\mbox{-}out[n] \\
8 & \ \ \ \ \ \ live\mbox{-}in[n] = Use[n] \cup (live\mbox{-}out[n] \setminus Def[n]) \\
9 & \ \ \ \ \ \ live\mbox{-}out[n] = \bigcup_{n' \in Succ[n]} live\mbox{-}in[n'] \\
10& until \ \forall n | live\mbox{-}in[n] = live\mbox{-}in'[n] \wedge live\mbox{-}out[n] = live\mbox{-}out'[n] \\
\end{array}

Für die Rückwärtsanalyse müssen wir einfach die Zeilen 8 und 9 vertauschen.

Beispiel
1 2 3
in out in out in out ...
1 \emptyset \emptyset \emptyset a \emptyset a ...
2 a \emptyset a c,b a,c c,b ...
3 c,b \emptyset c,b b c,b b ...
4 b \emptyset b a b a ...
5 a a a c,a a,c c,a ...
6 c \emptyset c \emptyset c \emptyset ...

\rightarrow Rückwärtsanalyse: Information wird gegen den Kontrollflußgraph propagiert.

1 2
1 a a,c c a,c ...
2 a,c c,b a,c b,c ...
3 c,b b,c b,c b,c ...
4 b,c a,c b,c a,c ...
5 a,c c a,c a,c ...
6 c \emptyset c \emptyset ...
Komplexität

Grundoperationen \rightarrow Mengenrepräsentation

N: = Größe des Programms (Anzahl an Instruktionen), W: = Wortbreite

OperationBitvektor geordnete Liste (ohne Wiederholungen)
Platzbedarf \frac{N}{W} N
Kopieren O(N) O(N)
\cup xor: O(N) O(N)
\setminus and/neg: O(N) O(N)
Vorteilhaft bei nichtbesetzten Mengen Vorteilhaft bei sparsam besetzten Mengen (\le \frac{N}{W} Elemente)

N ~ Anzahl an Variablen

  • erste Schleife: O(N2)
  • innere Schleife: O(N2)
  • repeat Schleife: O(NN)
Rhs der Datenflussgleichungen sind monoton steigend (
Autoren-Anmerkung(Firefox): Was sind Rhs ?
). Wenn die until-Bedingung nicht erfüllt ist muss mindestens ein Element hinzugefügt worden sein. Das passiert maximal 2 \cdot N^2-mal.

Die Datenflussgleichungen haben eine kleinste Lösung (wegen der Monotonie). Jede Lösung kann durch eine Variable, die nirgends definiert wird vergrößert werden. \rightarrow Es existiert keine eindeutige Lösung, aber aus der Monotonie kann eine eindeutige, kleinste Lösung gefolgert werden.

Für jede tatsächliche Lösung L1/L0 der Datenflussgleichungen gilt folgende Invariante der repeat-Schleife:

\forall n.live\mbox{-}in[n] \subseteq L1[n]

\forall n.live\mbox{-}out[n] \subseteq L0[n]

(informeller) Beweis:

  • bei Betreten der Schleife: ok
  • 
\begin{array}{lcl}
live\mbox{-}in[n] & = & Use[n] \cup (live\mbox{-}out[n] \setminus Def[n]) \\
& \subseteq & Use[n] \cup (L0[n] \setminus Def[n]) \\
& = & L1[n]
\end{array}
  • 
\begin{array}{lcl}
live\mbox{-}out[n] & = & \bigcup_{n' \in Succ[n]} live\mbox{-}in[n']\\
& \subseteq & \bigcup_{n' \in Succ[n]} L1[n'] \\
& = & L0[n]
\end{array}

\rightarrow Der Algorithmus findet also die kleinste Lösung, da beim Verlassen der repeat-Schleife die Datenflussgleichungen erfüllt sind.

Registerallokation

Interferenzgraph

Definition: Interferenzgraph

Der Interferenzgraph (IG) ist ein ungerichteter Graph mit

  • Knoten : = Menge der Pseudoregister
  • Kantenmenge E, sodass für m \in KFG gilt
    • falls m keine MOVE-Instruktion ist, dann (\forall t \in Def[m]) \ (\forall s \in live\mbox{-}out[m] \setminus \{ t \}) \ (s,t) \in E
    • falls m ist t: = r gilt (MOVE-Instruktion), dann (\forall s \in live\mbox{-}out[m] \setminus \{ t,r \}) \ (s,t) \in E

Bemerkung: Weitere Constraints können durch den Interferenzgraph ausgedrückt werden:

Autoren-Anmerkung(Firefox): Was ist ein Caller-Save Register ? Vielleicht ein spezieller Typ von Register bei dem das Register vom Aufrufer gespeichert werden muss ?

\rightarrow Zurückführung auf Graphfärbung des Interferenzgraphen.
Farbe \hat{=} Maschinenregister
Färbung ist Abbildung von Knoten(IG) auf Menge der Farben, sodass benachbarte Knoten unterschiedliche Farben erhalten

k Maschinenregister \rightarrow k-Färbung gesucht.

Probleme:

  • k-Färbung zu finden ist NP-Vollständig.
  • falls eine k-Färbung nicht möglich ist müssen Pseudoregister im Speicher abgelegt werden. (Spilling)
Lemma: Graphfärbung, Kempe, ~1820

Sei G = (V,E) ungerichtet und v \in V mit Grad(V) < k. Falls G \setminus \{v\} eine k-Färbung F' besitzt, dann exisitiert ein F mit: F ist k-Färbung von G.

Beweis: Färbungs-Lemma

Seien \{ v_1, \ldots, v_n\} = {neighbors}_G(v) \Rightarrow n < k, v_i \in G' und f eine Farbe.
\Rightarrow |\{ F'v_1, \ldots, F'v_n \}|<k
\Rightarrow \exists f \notin \{ F'v_1, \ldots, F'v_n \}, also ist F = F' [v \rightarrow f] eine k-Färbung von G.

Algorithmus

Build
Simplify
  • wähle Knoten v mit Grad(v) < k
  • entferne v und push v (alle Kanten zu v werden ebenfalls entfernt und reduzieren dadurch den Grad anderer Knoten)
  • wiederhole solange wie möglich. Danach gibt es nur noch Knoten v mit Grad(v) \ge k (oder gar keine)
Spill
  • wähle Knoten v mit Grad(v) \ge k
  • markiere v als "potential spill"
  • entferne v und push v (alle Kanten zu v werden ebenfalls entfernt und reduzieren dadurch den Grad anderer Knoten)
  • zurück zu Simplify und solange wiederholen bis der Interferenzgraph leer ist.
Select
  • let v = pop
  • falls v durch Simplify entfernt wurde (also keine Markierung trägt) wähle eine Farbe für v
  • falls v durch Spill entfernt wurde (also mit "potential spill" markiert ist) und färbbar ist , färbe v
  • ansonsten wird v als "actual spill" markiert und eine Speicherzelle dafür alloziiert. Außerdem wird der Code geändert:
    • STORE nach jeder Definition von v
    • LOAD vor jeder Benutzung von v
  • wiederhole bis der Stack leer ist.
End

Entweder ist der Stack nun leer und kein "actual spill" ist aufgetreten, oder der Code des Programms wurde geändert und der Algorithmus muss nochmal von Anfang an ausgeführt werden.

Vorgefärbte Register
  • Pseudoregister, die von vornherein bestimmten Registern zugeordnet sind (Argumentregister, Multiplikationsergebnis, etc.)
  • jedes vorgefärbte Register interferiert mit jedem anderen
  • dürfen nicht gespillt werden (\rightarrow Lebensbereiche klein halten mit MOVE) und werden von Simplify zuletzt gewählt damit Select ihnen zuerst die gewünschten Farben zuordnen kann.

Behandlung von MOVE / Coalescing (Verschmelzen)

  • Die Instruktion r:=s kann eliminiert werden falls (r,s) \notin IG
  • die zugehörigen Kanten im Interferenzgraphen werden verschmolzen \rightarrow Der Grad erhöht sich \rightarrow Die Färbbarkeit wird beeinträchtigt.
Vor Verschmelzung: 2-färbbar
\longrightarrow
Nach Verschmelzung: 3-färbbar

Also: Konservative Strategie

Verschmelze nur Register mit insgesamt nicht weniger als k Nachbarn mit Grad \ge k damit die k-Färbbarkeit erhalten bleibt.

Denn:(hinreichendes Kriterium)

Nach Entfernen der Kanten vom Grad < k (Simplify) verbleiben höchstens Nachbarn mit Grad \ge k, also hat der verschmolzene Knoten einen Grad < k und kann somit von Simplify entfernt werden.

Registerallokation mit Verschmelzen

Build
  • Erstelle den Interferenzgraphen.
  • Erzeuge einen Stack
  • \forall v:=s markiere v und s als "move-related".
Simplify

  • wähle unmarkierten Knoten v mit Grad(v) < k (berücksichtige obrige Bemerkungen zur Verschmelzung)
  • entferne v und push v (alle Kanten zu v werden ebenfalls entfernt und reduzieren dadurch den Grad anderer Knoten)
Coalescing
  • wähle markierten Knoten und verschmelze ihn mit "MOVE-Partner" (falls der Gesamtgrad < k ist )
  • entferne MOVE
  • entferne Markierung, falls der verschmolzene Knoten nicht mehr an MOVE beteiligt ist
  • gehe zu Simplify und wiederhole
Freeze
  • wähle markierten Knoten v mit Grad(v) < k
  • MOVE bleibt erhalten, Markierung wird entfernt
  • gehe zu Simplify und wiederhole
Spill
  • wie im normalen Algorithmus (Spill)
  • gehe zu Simplify und wiederhole bis der Graph leer ist
Select
  • wie im normalen Algorithmus (Select)

Registerallokation für Ausdrücke (Baumstruktur)

Baumstruktur
Baumstruktur

t1 braucht m Register

t2 braucht n Register

if max(m,n+1) > max(m+1,n)

then t1 zuerst

else t2 zuerst

Garbage Collection

Kapitel auf einer Seite lesen

In einem fertigen Programm ist die Garbage Collection (Müllentsorgung, GC) dafür zuständig, nicht mehr benutzte Objekte aus dem Speicher zu entfernen, um Platz für neue zu schaffen. Sie ist nicht automatisch Teil jeden Programms, sondern eine Optimierung, respektive Automatisierung.

Die Speicherverwaltung kann man in zwei Bereiche unterteilen:

  • lokale Variablen, Rücksprungadressen
    • Lebensdauer entspricht Funktionsaufruf
    • Anlegen auf dem Stack, Freigabe ist billig (Zurücksetzen des Stackpointers)
  • Closures, Vektoren, Objekte, dynamische Datenstrukturen
    • Lebensdauer unabhängig von Funktionsaufruf
    • Ablage auf Stack nicht möglich
    • Stattdessen Ablage im Heap

Bei der GC werden wir uns deshalb mit den im Heap abgelegten Objekten beschäftigen. Im Gegensatz zur manuellen Heapverwaltung (malloc/free, mit Gefahr von memory leaks, bzw. Fehlerpointern) legt man bei der GC die Objekte lediglich an, und die GC erledigt das Verwerfen automatisch.

Wir beginnen das Kapitel mit einer allgemeinen Einführung in die GC und werden dann einige Algorithmen vorstellen, um sie zu realisieren.

Allgemein

Die Garbage Collection hat zwei Aufgaben:

  1. Feststellen, welche Objekte im Heap noch lebendig (werden noch benutzt) sind.
  2. Freigeben der toten Obejkte.

Aufgabe 1 ist leider unentscheidbar (nach dem Satz von Rice). Wir können allerdings eine Approximation verwenden: Alle Objekte, die vom Stack und globalen Variablen (zusammen: Wurzeln) erreichbar sind, werden noch verwendet (sind lebendig). Dies ist eine sichere Approximation, da alle anderen Objekte nicht mehr durch eine Zeigerkette von der Wurzel aus erreicht werden können.

Um festzustellen, ob ein Objekt lebendig ist, müssen wir demnach den Zeigern folgen. Allerdings folgt daraus ein Problem: Wir müssen die Zeiger kennen. Das ist ein Problem, weil nicht jede Speicherzelle einen Zeiger enthält und bei manchen Sprachen die Eigenschaft, Zeiger zu sein, nicht statisch vorhersagbar ist (bei polymorphen Funktionen in OCaml beispielsweise).

Die Lösung für das Zeigererkennungsproblem nennt sich Tagging und verwendet 1-2 Bits zur Angabe des Typs:

Verfahren: Tagging

Ein Wort hat folgende Form: Image:6-Procedure-Word.png

Wir führen nun die Invariante ein, dass die letzten zwei Bits nicht benutzt werden. Dann gelingt die Darstellung von Zeigern und Zahlen so:

  • Zeiger: Image:6-Procedure-Pointer.png l r,s(-2), Adressierung mit Offset -2
  • Zahlen: Image:6-Procedure-Number.png Anpassen der Operationen auf 30-Bit Zahlen

Objektrepräsentation kann dann so gelöst werden:

Image:6-Procedure-Object.png

Mark & Sweep

Mark & Sweep benutzt eine sogenannte Freelist um freie Speicherbereiche zu markieren. Neue Objekte werden in einen freien Speicherbereich eingefügt, Adressen nicht mehr benutzter Objekte bei der Garbage Collection der Freelist hinzugefügt. Mark & Sweep (inklusive nichtrekursiver Tiefensuche) wurde schon in Lisp eingesetzt, es ist eine Technik der 60er Jahre.

Allokation

Wir gehen davon aus, dass alle aktuell freien Speicherbereiche bereits in der Freelist verkettet sind. Wir suchen dann einen passenden Bereich in der Freelist und entfernen ihn aus dieser, nachdem das Objekt dort eingefügt wurde.

Bei der Allokation können zwei Probleme auftauchen, wenn kein passender Speicherbereich vorhanden ist:

  • Alle Speicherbereiche sind zu klein, obwohl in der Summe ausreichend Speicher vorhanden ist (externe Fragmentierung). In diesem Fall führt man Garbage Collection mit Verschmelzen von benachbarten Speicherbereichen durch. Dies muss aber nicht zum Ziel führen, da Mark & Sweep nicht kompaktifiziert.
  • Alle Speicherbereiche sind zu gross. Tritt dieser Fall ein, muss man einen Speicherbereich aufspalten und nur teilweise verwenden. Es bleibt ein ungenutzter Rest (interne Fragmentierung), die Aufspaltung provoziert auch externe Fragmentierung.

Garbage Collection

Die Garbage Collection läuft in zwei Schritten ab:

  1. Tiefendurchlauf beginnend mit den Wurzeln, markiere alle besuchten Objekte (mark).
  2. linearer Durchlauf des Heaps (sweep):
    • nichtmarkierte Objekte kommen in die Freelist
    • bei markierten Objekten wird die Markierung entfernt

Ein Nachteil von Mark / Sweep ist die kostspielige rekursive Tiefensuche: sie verbraucht Stackplatz proportional zur Heapgröße - zu einer Zeit, wo der Heap voll ist. Man nutzt deshalb die Technik der nichtrekursiven Tiefensuche (Schorr-Waite). Die nichtrekursive Tiefensuche codiert den Stack in den Objekten durch Zeigerumkehrung mit zwei Zeigern:

Beispiel: Nichtrekursive Tiefensuche

Image:6-Nonrecursive-DFS.png

Mark & Sweep benötigt mit nichtrekursiver Tiefensuche konstanten Platz und eine lange Laufzeit.

Reference Count

Genau wie beim Mark & Sweep Algorithmus benutzt Reference Count eine Freelist. Beim Reference Count-Algorithmus besitzt jedes Objekt ausserdem einen Zähler, der die Anzahl der Zeiger auf das Objekt nachhält. Bei Operationen auf den Zeigern wird der Zähler angepasst:

  • jedes Kopieren eines Zeigers führt zur Inkrementierung des Zählers
  • jedes Verwerfen eines Zeigers führt zur Dekrementierung des Zählers

Falls der Zähler eines Objektes auf 0 fällt, wird das Objekt in die Freelist eingehängt.

Diese Verfahren ist langsam, benötigt zusätzlichen Code und Speicherzugriffe. Ein weiteres Problem ist, dass es zyklische Datenstrukturen nicht verarbeiten kann:

Beispiel: zykliche Datenstruktur

Image:6-Reference-Count.png

Um das Problem der zyklischen Datenstrukturen zu beheben, kann man zusätzliche Läufe von Mark & Sweep einsetzen.

Copying Collection

Die Copying Collection nutzt keine Freelist, sondern teilt den Heap in zwei Hälften gleicher Größe auf: FROMSPACE und TOSPACE. Der Algorithmus läuft dann wie folgt:

Image:6-Copying-Collection.png
  • Allokation wird linear fortlaufend im FROMSPACE vorgenommen.
  • Ist der FROMSPACE gefüllt, wird die Garbage Collection durchgeführt:
    • Beginnend bei den Wurzeln werden erreichbare Objekte in den TOSPACE kopiert.
    • Danach ist der FROMSPACE leer, die Rollen von FROMSPACE und TOSPACE werden getauscht.

Beim Kopieren der Objekte vom FROMSPACE zum TOSPACE während der Garbage Collection ändern sich alle Adressen, dies wird mit Hilfe von Umleiten gelöst. Der Algorithmus zum Anpassen eines Objektes (verschiebt Kind-Objekte) lässt sich so ausdrücken:

<div style="padding-left:4px;" id="Verfahren: forward">Verfahren: forward
forward(p) =
  if p \in TOSPACE then
    return p 
  else
    if p -> f_1 \in TOSPACE then
      return p -> f_1
    else
      foreach  f_i:
        next -> f_i = p -> f_i
      p -> f_1 = next
      next += size(p)
      return p -> f_1

</div>

Cheneys Algorithmus

Cheneys Algorithmus stellt eine Verbesserung der Copying Collection dar:

Algorithmus: Cheney
  1. foreach\ w \in \mbox{Wurzeln}
    \,\!w = forward(w)
  2. \,\!scan = \mbox{TOSPACE}
  3. \,\!while\ scan < next\ do
    foreach\ f_i
    scan \to f_i = forward(scan \to f_i)
    \,\!scan += size(scan)
  4. vertausche FROMSPACE und TOSPACE

Auch mit Cheneys Algorithmus gibt es Nachteile beim Einsatz von Copying Collection. Sie hat doppelten Speicherbedarf und zerstört durch das Kopieren (Breitensuche!) die Lokalität, was das Caching schwierig macht.

Generationed Collection

Generational Collection nutzt ein ähnliches Prinzip wie die Copying Collection, nur werden aufgrund einiger Beobachtungen mehr als zwei Speicherbereiche genutzt:

  • die meisten Objekte sind jung
  • Objekte, die schon mehrere GC überlebt haben, leben vermutlich noch länger

Damit die Garbage Collection effektiver wird, werden bei Generational Collection die jüngeren Objekte häufiger getestet, und der Heap in eine Reihe von Speicherbereichen G_0, \ldots, G_n aufgeteilt, die dem Alter der Objekte entsprechen.

Neue Objekte werden in G0 alloziert. Falls G0 voll ist, führt man eine Copying Collection auf G0 aus, mit G1 als TOSPACE. Wurde G0 häufig genug geleert oder sind G0 und G1 voll, führt man die Garbage Collection auf G0 und G1 aus: GC(G_0 + G_1 \to G_2).

Die Größe der Gi wird oft exponentiell größer gesetzt.

Garbage Collection

Wurzeln sind alle Objekte im Stack, globale Variablen sowie alle Objekte, die in "älteren" Generationen (als diejenigen im FROMSPACE) liegen, und "junge" Objekte referenzieren.

Beobachtung zeigt, dass alte Objekte selten auf junge zeigen, da dies nur durch "späte Zuweisung" möglich ist. Um herauszufinden, welche Objekte aus "älteren" Generationen referenziert werden, gibt es verschiedene Methoden:

  • Remembered Set: Jedes Update eines alten Objekts trägt die geschriebene Adresse in ein Remembered Set ein.
  • Card Marking: Teile die Gi in Blöcke der Größe 2k ein. Beim Update markiere in einem Bitvektor den betroffenen Block, bei GC betrachte alle Altobjekte des markierten Blocks als Wurzeln.
  • Page Marking: Verwende die Blöcke des virtuellen Speichers; markiere Blöcke aus alten Generationen als schreibgeschützt. Wird ein altes Objekt verändert, gibt es einen memory fault: fange diesen ab und markiere Block im Bitvektor.

Inkrementelle Garbage Collection

Alle bisher betrachteten Versionen von Garbage Collection haben das Problem, dass sie bei der Durchführung das laufende Programm anhalten müssen. Dieses Verhalten ist insbesondere in Echtzeitumgebungen unerwünscht. Eine Abhilfe schafft die inkrementelle Garbage Collection. Sie nutzt zwei Teile:

  • den Mutator, ein Programm, das die Arbeit macht, Objekte anlegt und verändert und
  • den Collector, der den Speicher bereinigt.

Es gibt zwei Varianten der inkrementellen Garbage Collection, die erste heisst einfach inkrementelle Garbage Collection: Hier stößt der Mutator den Collector an, der einen Teil der Garbage Collection erledigt. Die zweite Version ist die nebenläufige Garbage Collection, in der Mutator und Collector gleichzeitig arbeiten.

Grundlage aller inkrementeller Garbage Collection bildet ein 3-Farben Markierungsschema, das den Verarbeitungsstatus eines Objektes aus Sicht des Collectors beschreibt:

  • weiß bedeutet, dass das Objekt noch nicht besucht wurde,
  • ein graues Objekt wurde besucht, aber noch nicht alle seiner Kinder (bei Mark & Sweep etwa: Objekt befindet sich auf dem Stack, bei Cheney: in j-ter Queue, also zwischen scan und next),
  • ein schwarzes Objekt wurde besucht und ebenso alle seine direkten Kinder (d.h diese sind entweder grau oder schwarz).

Allokation

Zur Allokation eines Objektes mit N Worten kann man folgendes Schema nutzen:

Schema: Allokation in inkrementeller GC

Overhead für GC:

  1. Aufruf alloc(N)
  2. teste next + N < limit (sonst GC anstossen)
  3. lösche im Speicher M: M[next], ..., M[next + N - 1]
  4. result = next
  5. next += N
  6. return result

In jedem Fall notwendige Schritte für die Allokation:

  1. Verschiebe result in ein anderes Register
  2. Initialisiere Objekt

Das Schema kann noch optimiert werden, indem man einzelne Schritte zusammenfasst:

  • 1/6: inlining
  • 4/A: verschmelzen
  • 3/B: verschmelzen
  • 2/5: können für mehrere Allokationen in einem linearen Stück Code (Basic Block: Codestück ohne Sprünge) zusammengefasst werden.

Garbage Collection

Es gelten zwei Invarianten für den Mutator und den Collector:

  • ein schwarzes Objekt zeigt nie auf ein weißes
  • jedes graue Objekt ist in der Datenstruktur des Collectors
Collector

Es gilt: Alle Objekte sind weiß, Wurzeln befinden sich in grauen Wurzelobjekten.

Dann verfolgt der Collector dieses Vorgehen:

Verfahren: Collector

while\ \exists\ \mbox{graues Objekt}

p = ein graues Objekt

folgender Part muss atomar erfolgen:

foreach\ f_i
if\ p \to f_i\ \mbox{ist weiß}
färbe fi grau (in Datenstruktur des Collectors)
färbe p schwarz
Mutator

Für den Mutator gibt es verschiedene Möglichkeiten:

  • Jedes Update wird instrumentiert mit einem Test, ob ein weißes Objekt in ein schwarzes Objekt geschrieben wird: wenn dies geschieht, muss man das schwarze Objekt in der Datenstruktur des Collectors grau färben (write barrier: Software oder Hardware).
  • Der Collector markiert Seiten, die nur schwarze Objekte enthalten. Bei Schreibzugriff wird die ganze Seite grau gefärbt.
  • Wenn der Mutator auf Objekt b zugreift, färbt er es grau und verschiebt es in die Datenstruktur des Collectors, also kann Invariante 1 nie verletzt werden (read barrier).

Bahrers Algorithmus

Der Algorithmus von Bahrer ist eine inkrementelle Version von Cheneys Algorithmus. Er hat eine zusätzliche Invariante zu den beiden inkrementellen Invarianten:

  • Der Mutator benutzt nur TOSPACE Zeiger.

Die Garbage Collection läuft dann folgendermassen ab:

Verfahren: Bahrers Algorithmus
Image:6-Bahrers-Algorithm.png

Die Allokation findet findet im TOSPACE in einem zusammenhängenden Speicherbereich statt, der durch next und limit begrenzt ist (siehe Illustration).

  1. FLIP:
    • vertausche FROMSPACE und TOSPACE
    • leite alle Wurzeln um (in den TOSPACE)
  2. bei jeder Allokation:
    • leite einige Zeiger um (inkrementeller Part)
    • lege neues Objekt am Ende des TOSPACE ab
  3. bei jedem Feldzugriff könnte ein Zeiger auf ein weißes Objekt benutzt werden - falls der Wert des Feldzugriffs in den FROMSPACE zeigt, wird das Objekt in den TOSPACE umgeleitet.
  4. pausiere, falls scan = next (siehe Illustration)

Interface zum Compiler

Die Garbage Collection wird im Compiler vorbereitet. Er kümmert sich um die Anpassung von

  • Allokation
  • Datenlayout von Heapobjekten (zum Finden/Markieren von Zeigern)
  • Wurzeln
  • Code für read/write Barrieren

Glossar

Den Glossar findet man auf einer eigenen Seite.

Literatur & externe Links

Literatur & externe Links auf einer Seite lesen

Literatur

externe Links

nicht aufgelöste Quellen

  • Bochmann Normal Form: [Boc78]